Skip to content

feat(audio): add independent track mixing and transcript timing - #167

Open
DonIsmaelito wants to merge 5 commits into
browser-use:mainfrom
DonIsmaelito:submit/mixing
Open

DonIsmaelito wants to merge 5 commits into
browser-use:mainfrom
DonIsmaelito:submit/mixing

Conversation

@DonIsmaelito

@DonIsmaelito DonIsmaelito commented Sep 17, 2026

Copy link
Copy Markdown

Why

Music should keep playing smoothly when the picture cuts, and speech should stay lined up with its captions. These helpers let the agent control music, speech and sound effects separately, and keep word timestamps aligned with the edited audio.

Builds on #164 for shared media and timing helpers.

Changes

Four focused commits, with tests alongside each feature:

  • Decode exact audio windows and apply clip gain — trim after resampling, support gain automation and duration-preserving EQ, and apply voice fades without automatically fading music.
  • Mix independent audio buses and normalize the master — place voice, music and effects independently; export stems, a two-pass normalized master and a report. Honor the declared frame rate and reject invalid placements or existing output files.
  • Map intact transcript words onto the audio timeline — preserve source/output sample ranges, reject word-cutting trims, and report transcript differences and onset offsets. Protect input documents and filesystem aliases from overwrite.
  • Document independent audio mixing and speech timing — add a manifest example, commands and limits in references/audio-mixing.md and expose the helpers in SKILL.md.

All 83 branch tests passed, including 47 mixing/transcript cases with real FFmpeg decoding, stereo sample checks and independent master loudness measurement. CLI, comment, skill-rule and lockfile checks passed.

Limits

@DonIsmaelito
DonIsmaelito marked this pull request as ready for review September 17, 2026 20:12

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="pyproject.toml">

<violation number="1" location="pyproject.toml:28">
P2: With pytest older than 7, this `pythonpath` option is ignored and the documented test commands fail on bare helper imports such as `from mix_audio import ...`. Declare a pytest 7+ test requirement or provide a version-independent helper import setup.</violation>
</file>

<file name="references/sources.md">

<violation number="1" location="references/sources.md:23">
P2: The catalog does not guarantee original source PTS because `source_scan.py` records FFmpeg best-effort timestamps. Change this wording to “best-effort presentation timestamps” or update the helper to emit original `pts_time` values before consumers rely on them for exact alignment.</violation>
</file>

<file name="helpers/map_transcript.py">

<violation number="1" location="helpers/map_transcript.py:26">
P3: When a word starts slightly before zero, `seconds_to_sample` rounds it to sample 0 and this check accepts it as valid. Validate the raw timestamps before converting them to samples.</violation>
</file>

<file name="helpers/find_shot.py">

<violation number="1" location="helpers/find_shot.py:50">
P2: When `--every nan` is supplied, this check accepts it and the sampler silently examines only the first frame. Reject non-finite intervals before sampling.</violation>

<violation number="2" location="helpers/find_shot.py:89">
P1: When `--out` is a hard link to the query or source, this check passes and `save_json` destroys the input file. Use `samefile()` for existing output aliases before writing.</violation>
</file>

<file name="helpers/project_state.py">

<violation number="1" location="helpers/project_state.py:47">
P2: When a persisted artifact row lacks `sha256`, `show` crashes with `KeyError` after `validate` accepts it. Require the generated fingerprint during validation before `view` indexes it.</violation>

<violation number="2" location="helpers/project_state.py:57">
P1: When an artifact path is the context itself or a filesystem alias, `record` hashes it and then overwrites the context via `save_json`. Reject the resolved path and `samefile` aliases before hashing.</violation>

<violation number="3" location="helpers/project_state.py:62">
P3: When a new artifact names a missing or cyclic dependency, this hash comprehension runs before `validate`, so `record` raises `KeyError` and bypasses dependency diagnostics. Insert the candidate row and validate it before computing dependency hashes.</violation>
</file>

<file name="helpers/source_scan.py">

<violation number="1" location="helpers/source_scan.py:80">
P1: When an input contains multiple video streams, FFmpeg can decode a different stream than the `v:0` stream cataloged above. Map `0:v:0` explicitly so selected frame indices and dimensions refer to the cataloged stream.</violation>

<violation number="2" location="helpers/source_scan.py:142">
P1: When `--out` is a hard link to the source, `resolve()` does not identify the alias and `save_json()` truncates the source file. Reject existing aliases with `samefile()` before writing the catalog.</violation>
</file>

<file name="helpers/edit_clock.py">

<violation number="1" location="helpers/edit_clock.py:59">
P2: When the frame budget is at least the number of positive-weight shots but a small quota floors to zero, `allocate_frames` raises instead of producing a valid partition. Reserve one frame per shot before applying largest-remainder allocation so valid short shots are not rejected.</violation>
</file>

<file name="helpers/prepare_source.py">

<violation number="1" location="helpers/prepare_source.py:13">
P2: When `<out>.log` or `<out>.json` already exists, this guard checks only the MKV, so preparation overwrites the prior artifacts. Reject all derivative artifact paths before creating the output.</violation>

<violation number="2" location="helpers/prepare_source.py:25">
P2: When callers pass an explicitly supplied empty crop, `if crop` skips validation and silently ignores it. Check `crop is not None` so every supplied crop is validated.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread helpers/project_state.py
"""Save an artifact fingerprint and the dependency versions it was built from."""
context = Path(context)
data = load_json(context) if context.exists() else {"version": 1, "artifacts": {}}
path = resolve(context.parent, entry["path"])

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an artifact path is the context itself or a filesystem alias, record hashes it and then overwrites the context via save_json. Reject the resolved path and samefile aliases before hashing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/project_state.py, line 57:

<comment>When an artifact path is the context itself or a filesystem alias, `record` hashes it and then overwrites the context via `save_json`. Reject the resolved path and `samefile` aliases before hashing.</comment>

<file context>
@@ -0,0 +1,126 @@
+    """Save an artifact fingerprint and the dependency versions it was built from."""
+    context = Path(context)
+    data = load_json(context) if context.exists() else {"version": 1, "artifacts": {}}
+    path = resolve(context.parent, entry["path"])
+    if not path.is_file():
+        raise FileNotFoundError(path)
</file context>
Suggested change
path = resolve(context.parent, entry["path"])
path = resolve(context.parent, entry["path"])
if path == context.resolve() or (
path.exists() and context.exists() and path.samefile(context)
):
raise ValueError("context cannot also be an artifact")
Fix with cubic

Comment thread helpers/source_scan.py
"2",
"-i",
str(path),
"-an",

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an input contains multiple video streams, FFmpeg can decode a different stream than the v:0 stream cataloged above. Map 0:v:0 explicitly so selected frame indices and dimensions refer to the cataloged stream.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/source_scan.py, line 80:

<comment>When an input contains multiple video streams, FFmpeg can decode a different stream than the `v:0` stream cataloged above. Map `0:v:0` explicitly so selected frame indices and dimensions refer to the cataloged stream.</comment>

<file context>
@@ -0,0 +1,151 @@
+            "2",
+            "-i",
+            str(path),
+            "-an",
+            "-sn",
+            "-dn",
</file context>
Fix with cubic

Comment thread helpers/source_scan.py
parser.add_argument("--out", required=True)
parser.add_argument("--scenes", action="store_true")
args = parser.parse_args()
if Path(args.source).resolve() == Path(args.out).resolve():

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When --out is a hard link to the source, resolve() does not identify the alias and save_json() truncates the source file. Reject existing aliases with samefile() before writing the catalog.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/source_scan.py, line 142:

<comment>When `--out` is a hard link to the source, `resolve()` does not identify the alias and `save_json()` truncates the source file. Reject existing aliases with `samefile()` before writing the catalog.</comment>

<file context>
@@ -0,0 +1,151 @@
+    parser.add_argument("--out", required=True)
+    parser.add_argument("--scenes", action="store_true")
+    args = parser.parse_args()
+    if Path(args.source).resolve() == Path(args.out).resolve():
+        parser.error("output cannot replace source")
+    data = catalog(args.source)
</file context>
Fix with cubic

Comment thread helpers/find_shot.py
p.add_argument("--every", type=float, default=1)
p.add_argument("--out", required=True)
a = p.parse_args()
if Path(a.out).resolve() in (Path(a.query).resolve(), Path(a.source).resolve()):

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When --out is a hard link to the query or source, this check passes and save_json destroys the input file. Use samefile() for existing output aliases before writing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/find_shot.py, line 89:

<comment>When `--out` is a hard link to the query or source, this check passes and `save_json` destroys the input file. Use `samefile()` for existing output aliases before writing.</comment>

<file context>
@@ -0,0 +1,95 @@
+    p.add_argument("--every", type=float, default=1)
+    p.add_argument("--out", required=True)
+    a = p.parse_args()
+    if Path(a.out).resolve() in (Path(a.query).resolve(), Path(a.source).resolve()):
+        p.error("output would overwrite input")
+    save_json(a.out, search(a.query, a.source, a.every))
</file context>
Suggested change
if Path(a.out).resolve() in (Path(a.query).resolve(), Path(a.source).resolve()):
output = Path(a.out).resolve()
inputs = [Path(a.query).resolve(), Path(a.source).resolve()]
if any(
output == path
or (output.exists() and path.exists() and output.samefile(path))
for path in inputs
):
Fix with cubic

Comment thread pyproject.toml
py-modules = []

[tool.pytest.ini_options]
pythonpath = [".", "helpers"]

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: With pytest older than 7, this pythonpath option is ignored and the documented test commands fail on bare helper imports such as from mix_audio import .... Declare a pytest 7+ test requirement or provide a version-independent helper import setup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pyproject.toml, line 28:

<comment>With pytest older than 7, this `pythonpath` option is ignored and the documented test commands fail on bare helper imports such as `from mix_audio import ...`. Declare a pytest 7+ test requirement or provide a version-independent helper import setup.</comment>

<file context>
@@ -10,14 +10,20 @@ dependencies = [
 py-modules = []
+
+[tool.pytest.ini_options]
+pythonpath = [".", "helpers"]
+testpaths = ["tests"]
</file context>
Fix with cubic

Comment thread helpers/edit_clock.py
Comment on lines +59 to +66
exact = [fraction(w) * total / sum(map(fraction, weights)) for w in weights]
counts = [math.floor(v) for v in exact]
for i in sorted(
range(len(weights)), key=lambda i: (exact[i] - counts[i], -i), reverse=True
)[: total - sum(counts)]:
counts[i] += 1
if min(counts) < 1:
raise ValueError("duration is too short for these shot proportions")

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the frame budget is at least the number of positive-weight shots but a small quota floors to zero, allocate_frames raises instead of producing a valid partition. Reserve one frame per shot before applying largest-remainder allocation so valid short shots are not rejected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/edit_clock.py, line 59:

<comment>When the frame budget is at least the number of positive-weight shots but a small quota floors to zero, `allocate_frames` raises instead of producing a valid partition. Reserve one frame per shot before applying largest-remainder allocation so valid short shots are not rejected.</comment>

<file context>
@@ -0,0 +1,87 @@
+    """Largest-remainder allocation preserves total; it does not choose edit points."""
+    if not weights or total < len(weights) or any(fraction(w) <= 0 for w in weights):
+        raise ValueError("positive weights and at least one frame per shot required")
+    exact = [fraction(w) * total / sum(map(fraction, weights)) for w in weights]
+    counts = [math.floor(v) for v in exact]
+    for i in sorted(
</file context>
Suggested change
exact = [fraction(w) * total / sum(map(fraction, weights)) for w in weights]
counts = [math.floor(v) for v in exact]
for i in sorted(
range(len(weights)), key=lambda i: (exact[i] - counts[i], -i), reverse=True
)[: total - sum(counts)]:
counts[i] += 1
if min(counts) < 1:
raise ValueError("duration is too short for these shot proportions")
remaining = total - len(weights)
exact = [fraction(w) * remaining / sum(map(fraction, weights)) for w in weights]
floors = [math.floor(v) for v in exact]
counts = [1 + floor for floor in floors]
for i in sorted(
range(len(weights)), key=lambda i: (exact[i] - floors[i], -i), reverse=True
)[: remaining - sum(floors)]:
counts[i] += 1
Fix with cubic

Comment thread helpers/prepare_source.py
"""Create a new FFV1 working copy and record its explicit transformations."""
source = Path(source).resolve()
out = Path(out).resolve()
if out.exists() or source == out:

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When <out>.log or <out>.json already exists, this guard checks only the MKV, so preparation overwrites the prior artifacts. Reject all derivative artifact paths before creating the output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/prepare_source.py, line 13:

<comment>When `<out>.log` or `<out>.json` already exists, this guard checks only the MKV, so preparation overwrites the prior artifacts. Reject all derivative artifact paths before creating the output.</comment>

<file context>
@@ -0,0 +1,111 @@
+    """Create a new FFV1 working copy and record its explicit transformations."""
+    source = Path(source).resolve()
+    out = Path(out).resolve()
+    if out.exists() or source == out:
+        raise FileExistsError("choose a new source derivative path")
+    if out.suffix.lower() != ".mkv":
</file context>
Fix with cubic

Comment thread helpers/prepare_source.py
if tonemap and not hdr:
raise ValueError("tonemap requires tagged PQ or HLG input")
filters = []
if crop:

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When callers pass an explicitly supplied empty crop, if crop skips validation and silently ignores it. Check crop is not None so every supplied crop is validated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/prepare_source.py, line 25:

<comment>When callers pass an explicitly supplied empty crop, `if crop` skips validation and silently ignores it. Check `crop is not None` so every supplied crop is validated.</comment>

<file context>
@@ -0,0 +1,111 @@
+    if tonemap and not hdr:
+        raise ValueError("tonemap requires tagged PQ or HLG input")
+    filters = []
+    if crop:
+        if (
+            len(crop) != 4
</file context>
Suggested change
if crop:
if crop is not None:
Fix with cubic

Comment thread helpers/map_transcript.py
continue
a = seconds_to_sample(word["start"])
b = seconds_to_sample(word["end"])
if a < 0 or b <= a:

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a word starts slightly before zero, seconds_to_sample rounds it to sample 0 and this check accepts it as valid. Validate the raw timestamps before converting them to samples.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/map_transcript.py, line 26:

<comment>When a word starts slightly before zero, `seconds_to_sample` rounds it to sample 0 and this check accepts it as valid. Validate the raw timestamps before converting them to samples.</comment>

<file context>
@@ -0,0 +1,135 @@
+            continue
+        a = seconds_to_sample(word["start"])
+        b = seconds_to_sample(word["end"])
+        if a < 0 or b <= a:
+            raise ValueError(
+                "word timestamps must describe a positive nonnegative interval"
</file context>
Suggested change
if a < 0 or b <= a:
if word["start"] < 0 or word["end"] <= word["start"] or b <= a:
Fix with cubic

Comment thread helpers/project_state.py
Comment on lines +62 to +66
entry["dependency_hashes"] = {
dep: data["artifacts"][dep]["sha256"] for dep in entry.get("depends_on", [])
}
data["artifacts"][ident] = entry
validate(data)

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a new artifact names a missing or cyclic dependency, this hash comprehension runs before validate, so record raises KeyError and bypasses dependency diagnostics. Insert the candidate row and validate it before computing dependency hashes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/project_state.py, line 62:

<comment>When a new artifact names a missing or cyclic dependency, this hash comprehension runs before `validate`, so `record` raises `KeyError` and bypasses dependency diagnostics. Insert the candidate row and validate it before computing dependency hashes.</comment>

<file context>
@@ -0,0 +1,126 @@
+        raise FileNotFoundError(path)
+    entry = dict(entry)
+    entry["sha256"] = sha256(path)
+    entry["dependency_hashes"] = {
+        dep: data["artifacts"][dep]["sha256"] for dep in entry.get("depends_on", [])
+    }
</file context>
Suggested change
entry["dependency_hashes"] = {
dep: data["artifacts"][dep]["sha256"] for dep in entry.get("depends_on", [])
}
data["artifacts"][ident] = entry
validate(data)
data["artifacts"][ident] = entry
validate(data)
entry["dependency_hashes"] = {
dep: data["artifacts"][dep]["sha256"] for dep in entry.get("depends_on", [])
}
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant