Skip to content

feat(audio): add beat analysis recording matching and motion controls - #166

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

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

Conversation

@DonIsmaelito

@DonIsmaelito DonIsmaelito commented Sep 17, 2026

Copy link
Copy Markdown

Why

Editing to music needs measured timing, recording correspondence and audio signals that authored motion can follow. Without shared helpers, agents need separate analysis scripts for those decisions. This adds reusable measurements while leaving musical and visual choices to the editor.

Builds on #164 for shared media IO. It does not depend on Frames or local transcription.

Changes

Four focused commits, with tests alongside each feature:

  • Measure song beats and frequency band accentssong_scan.py reports estimated tempo, beat times, loudness and candidate energy rises; declares SciPy as a direct dependency.
  • Match recording excerpts against supplied candidatesidentify_track.py ranks waveform correspondence and reports offsets; protects the query, catalog and candidate files from output overwrites, including hard-link aliases.
  • Extract timestamped audio controls for authored motionmotion_audio.py exports loudness, smoothed envelopes, onsets and frequency-band controls with explicit timeline offsets and normalization; protects source aliases.
  • Document audio analysis commands and measurement limits — add references/audio-analysis.md and helper discovery in SKILL.md.

All 64 branch tests passed, including 28 audio-analysis cases. CLI checks, comment conventions, skill-rule preservation and lockfile checks passed.

Limits

  • Beats, accents and energy rises are candidates to audition, not instrument labels or automatic edit decisions. Beat analysis overlaps feat(render): background music mixing with voice ducking + beat analysis #39 and needs interface coordination if both land.
  • Recording matching searches only supplied candidates, using their first ten minutes by default. It is not global song recognition or proof of rights; remixes, speed changes and dialogue can defeat waveform matching.
  • Motion controls require an animation consumer. Centered windows can anticipate attacks, mono downmixing can cancel opposite-phase stereo, and independently normalized bands are not directly comparable.
  • Analysis holds decoded audio in memory. Use bounded sources or motion-audio start/duration options for long recordings. These helpers do not generate or mix audio, transcribe speech, or establish perceptual sync quality.

@DonIsmaelito
DonIsmaelito marked this pull request as ready for review September 17, 2026 19:50

@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.

22 issues found across 18 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="helpers/identify_track.py">

<violation number="1" location="helpers/identify_track.py:62">
P2: When callers provide a zero, negative, or non-finite `rate`, `align` can divide by zero or emit invalid offsets, and `decode` forwards the invalid rate to FFmpeg. Validate the sample rate as a finite positive value in both public functions before using it.</violation>
</file>

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

<violation number="1" location="helpers/edit_clock.py:25">
P3: When callers pass zero or a negative `fps`, `seconds_to_frame` accepts an invalid clock and can return a bogus boundary. Validate that `fps` is strictly positive before calculating.</violation>

<violation number="2" location="helpers/edit_clock.py:59">
P2: When a small weight's proportional share is below one frame, `allocate_frames` floors it to zero and assigns all remainders to larger weights, then raises. Reserve one frame per shot before distributing the remaining budget.</violation>
</file>

<file name="references/audio-analysis.md">

<violation number="1" location="references/audio-analysis.md:51">
P2: Queries longer than ten minutes are not rejected: `decode(query)` truncates them to the ten-minute window, so `rank` only matches the first ten minutes. Document this truncation rather than claiming overlong queries raise an error, or add explicit duration validation.</violation>
</file>

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

<violation number="1" location="helpers/project_state.py:62">
P3: When `depends_on` names an artifact that was never recorded, this dict comprehension raises a bare `KeyError` from `data["artifacts"][dep]`, so the intended `validate(data)` message about unknown dependencies never runs. The docs tell users to record dependencies first, making this a likely first-time error; give it a clear message before indexing.</violation>

<violation number="2" location="helpers/project_state.py:86">
P2: When `context.json` contains an otherwise valid artifact row without `sha256`, `view()` crashes instead of reporting stale evidence. Require the fingerprint during validation or treat a missing fingerprint as stale before comparing hashes.</violation>
</file>

<file name="tests/test_motion_audio.py">

<violation number="1" location="tests/test_motion_audio.py:161">
P3: This file mixes two test styles: MotionAudioTests is a unittest.TestCase, while test_cli_preserves_hardlinked_recording is a plain pytest function that depends on the tmp_path fixture and sits after the if __name__ == "__main__": unittest.main() guard. Under unittest discovery or `python tests/test_motion_audio.py`, the class runs but the hardlink-overwrite regression test silently never executes, dropping the coverage this PR explicitly adds. Convert the function into a unittest test (e.g. tempfile.TemporaryDirectory instead of tmp_path) or make the whole file pytest-style like the other test files.</violation>
</file>

<file name="pyproject.toml">

<violation number="1" location="pyproject.toml:27">
P2: This PR adds a pytest config block (and a test suite run via pytest), but pytest is not declared anywhere in pyproject.toml and uv.lock contains no pytest package. A fresh checkout running `uv sync` then `uv run pytest` (or `pytest`) fails because pytest is not installed, so the newly added tests from this PR are not runnable from a clean environment without a manual install. references/sources.md even states "Tests additionally require pytest" without providing any dependency declaration. Declare pytest as a dev/test dependency so the shipped test config and tests are reproducible.</violation>
</file>

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

<violation number="1" location="helpers/find_shot.py:50">
P2: When `--every` is `nan` or `inf`, search succeeds but returns candidates from only the first frame. Reject nonfinite sampling intervals with `np.isfinite` before sampling.</violation>

<violation number="2" location="helpers/find_shot.py:74">
P3: When a homography fits perfectly, `reprojection_px` is `0.0`, and `0.0 or 1e9` evaluates to `1e9` because 0.0 is falsy. Among candidates tied on inlier count, the best (zero-error) fit is therefore ranked last instead of first. Treat only `None` as the missing value.</violation>

<violation number="3" location="helpers/find_shot.py:89">
P1: When `--out` is a hard link to `query` or `source`, this guard passes because hard links have different resolved pathnames. `save_json` then truncates the shared inode and destroys the input; compare existing paths with `Path.samefile()` as the other helpers do.</violation>
</file>

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

<violation number="1" location="helpers/prepare_source.py:25">
P3: When callers pass an empty crop sequence, this guard silently treats malformed crop input as no crop. Test `crop is not None` so every supplied crop reaches the four-value validation.</violation>

<violation number="2" location="helpers/prepare_source.py:83">
P1: When the source aliases either generated sidecar, this preparation corrupts the input after the media collision check passes. Reject source collisions with the `.log` and `.json` paths before running FFmpeg or writing metadata.</violation>

<violation number="3" location="helpers/prepare_source.py:83">
P3: If ffmpeg fails mid-encode (e.g. missing zscale, filter init failure), a partial `out.mkv` is left on disk; every later `prepare` call then raises `FileExistsError("choose a new source derivative path")` even after the real cause is fixed. Remove the partial output when `run()` raises so retries are possible.</violation>
</file>

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

<violation number="1" location="helpers/source_scan.py:16">
P3: If the source contains no video stream (audio-only or container-only file), this `next()` raises a bare `StopIteration` traceback. The same pattern is repeated in `selected_frames()`. Raise a clear error naming the file instead.</violation>

<violation number="2" location="helpers/source_scan.py:80">
P1: For files with multiple video streams, `selected_frames` can decode a different stream from the one cataloged by `catalog()`. Add `-map 0:v:0` so selected pixels use the cataloged frame indices and PTS.</violation>

<violation number="3" location="helpers/source_scan.py:142">
P1: When `--out` is a hard link to `--source`, this check passes and `save_json` truncates the source media while writing the catalog. Reject existing aliases with `Path(args.out).samefile(args.source)` before writing.</violation>
</file>

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

<violation number="1" location="helpers/song_scan.py:12">
P2: With `--accents`, a nonempty recording shorter than 897 samples (~41 ms at 22.05 kHz) makes SciPy shrink `nperseg` below `noverlap=896`, so `scan` raises instead of writing a report. Clamp both framing values to the actual sample length before calling `stft`.</violation>
</file>

<file name="tests/test_song_scan.py">

<violation number="1" location="tests/test_song_scan.py:70">
P3: On Windows, `Path.symlink_to` raises OSError (WinError 1314) unless the process runs elevated or Developer Mode is enabled, so this test errors out instead of exercising the CLI guard. Skip the symlink case when symlink creation is unavailable, e.g. `pytest.skip` after catching the OSError, so the suite stays runnable on restricted Windows environments.</violation>
</file>

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

<violation number="1" location="helpers/edit_io.py:93">
P3: `last_json` is dead code: no current helper calls it, so this new shared API is untested and has no effect. Remove it until a command parser uses it, or wire it into the existing JSON measurement parsing.</violation>

<violation number="2" location="helpers/edit_io.py:95">
P2: When the command’s JSON measurement contains nested objects, this reverse-brace scan returns the innermost object rather than the enclosing measurement. Track decoded object ranges and select the last top-level candidate before returning it.</violation>
</file>

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

<violation number="1" location="helpers/motion_audio.py:120">
P2: With `--sample-rate 8000` or `16000`, the default treble band exceeds Nyquist and `analyze_samples` rejects valid CLI input. Clamp default band edges to Nyquist or raise the minimum sample rate.</violation>
</file>

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

Re-trigger 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 query or source, this guard passes because hard links have different resolved pathnames. save_json then truncates the shared inode and destroys the input; compare existing paths with Path.samefile() as the other helpers do.

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 `query` or `source`, this guard passes because hard links have different resolved pathnames. `save_json` then truncates the shared inode and destroys the input; compare existing paths with `Path.samefile()` as the other helpers do.</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 helpers/prepare_source.py
"-colorspace",
"bt709",
]
run(args + [out], log=Path(str(out) + ".log"))

@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 the source aliases either generated sidecar, this preparation corrupts the input after the media collision check passes. Reject source collisions with the .log and .json paths before running FFmpeg or writing metadata.

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 83:

<comment>When the source aliases either generated sidecar, this preparation corrupts the input after the media collision check passes. Reject source collisions with the `.log` and `.json` paths before running FFmpeg or writing metadata.</comment>

<file context>
@@ -0,0 +1,111 @@
+            "-colorspace",
+            "bt709",
+        ]
+    run(args + [out], log=Path(str(out) + ".log"))
+    result = {
+        "source": str(source),
</file context>
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: For files with multiple video streams, selected_frames can decode a different stream from the one cataloged by catalog(). Add -map 0:v:0 so selected pixels use the cataloged frame indices and PTS.

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>For files with multiple video streams, `selected_frames` can decode a different stream from the one cataloged by `catalog()`. Add `-map 0:v:0` so selected pixels use the cataloged frame indices and PTS.</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 --source, this check passes and save_json truncates the source media while writing the catalog. Reject existing aliases with Path(args.out).samefile(args.source) before writing.

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 `--source`, this check passes and `save_json` truncates the source media while writing the catalog. Reject existing aliases with `Path(args.out).samefile(args.source)` before writing.</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/identify_track.py
index = int(np.argmax(scores))
return {
"offset_samples": index,
"offset_seconds": index / rate,

@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 provide a zero, negative, or non-finite rate, align can divide by zero or emit invalid offsets, and decode forwards the invalid rate to FFmpeg. Validate the sample rate as a finite positive value in both public functions before using it.

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

<comment>When callers provide a zero, negative, or non-finite `rate`, `align` can divide by zero or emit invalid offsets, and `decode` forwards the invalid rate to FFmpeg. Validate the sample rate as a finite positive value in both public functions before using it.</comment>

<file context>
@@ -0,0 +1,111 @@
+    index = int(np.argmax(scores))
+    return {
+        "offset_samples": index,
+        "offset_seconds": index / rate,
+        "correlation": float(np.clip(scores[index], -1, 1)),
+        "sample_rate": rate,
</file context>
Fix with cubic

Comment thread helpers/edit_clock.py
# convert seconds to a frame boundary using the requested rounding policy
def seconds_to_frame(seconds, fps=30, mode="nearest"):
"""Convert seconds to a frame boundary using the requested rounding policy."""
value = fraction(seconds) * fraction(fps)

@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 callers pass zero or a negative fps, seconds_to_frame accepts an invalid clock and can return a bogus boundary. Validate that fps is strictly positive before calculating.

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 25:

<comment>When callers pass zero or a negative `fps`, `seconds_to_frame` accepts an invalid clock and can return a bogus boundary. Validate that `fps` is strictly positive before calculating.</comment>

<file context>
@@ -0,0 +1,87 @@
+# convert seconds to a frame boundary using the requested rounding policy
+def seconds_to_frame(seconds, fps=30, mode="nearest"):
+    """Convert seconds to a frame boundary using the requested rounding policy."""
+    value = fraction(seconds) * fraction(fps)
+    if mode == "nearest":
+        return nearest(value)
</file context>
Fix with cubic

Comment thread helpers/project_state.py
raise FileNotFoundError(path)
entry = dict(entry)
entry["sha256"] = sha256(path)
entry["dependency_hashes"] = {

@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 depends_on names an artifact that was never recorded, this dict comprehension raises a bare KeyError from data["artifacts"][dep], so the intended validate(data) message about unknown dependencies never runs. The docs tell users to record dependencies first, making this a likely first-time error; give it a clear message before indexing.

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 `depends_on` names an artifact that was never recorded, this dict comprehension raises a bare `KeyError` from `data["artifacts"][dep]`, so the intended `validate(data)` message about unknown dependencies never runs. The docs tell users to record dependencies first, making this a likely first-time error; give it a clear message before indexing.</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>
Fix with cubic

Comment thread helpers/prepare_source.py
"-colorspace",
"bt709",
]
run(args + [out], log=Path(str(out) + ".log"))

@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: If ffmpeg fails mid-encode (e.g. missing zscale, filter init failure), a partial out.mkv is left on disk; every later prepare call then raises FileExistsError("choose a new source derivative path") even after the real cause is fixed. Remove the partial output when run() raises so retries are possible.

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 83:

<comment>If ffmpeg fails mid-encode (e.g. missing zscale, filter init failure), a partial `out.mkv` is left on disk; every later `prepare` call then raises `FileExistsError("choose a new source derivative path")` even after the real cause is fixed. Remove the partial output when `run()` raises so retries are possible.</comment>

<file context>
@@ -0,0 +1,111 @@
+            "-colorspace",
+            "bt709",
+        ]
+    run(args + [out], log=Path(str(out) + ".log"))
+    result = {
+        "source": str(source),
</file context>
Fix with cubic

Comment thread helpers/source_scan.py
def catalog(path):
"""Record native presentation timestamps and the source file fingerprint."""
data = probe(path)
stream = next(s for s in data["streams"] if s["codec_type"] == "video")

@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: If the source contains no video stream (audio-only or container-only file), this next() raises a bare StopIteration traceback. The same pattern is repeated in selected_frames(). Raise a clear error naming the file instead.

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 16:

<comment>If the source contains no video stream (audio-only or container-only file), this `next()` raises a bare `StopIteration` traceback. The same pattern is repeated in `selected_frames()`. Raise a clear error naming the file instead.</comment>

<file context>
@@ -0,0 +1,151 @@
+def catalog(path):
+    """Record native presentation timestamps and the source file fingerprint."""
+    data = probe(path)
+    stream = next(s for s in data["streams"] if s["codec_type"] == "video")
+    frames = json.loads(
+        run(
</file context>
Suggested change
stream = next(s for s in data["streams"] if s["codec_type"] == "video")
streams = [s for s in data["streams"] if s["codec_type"] == "video"]
if not streams:
raise ValueError(f"{path} has no video stream")
stream = streams[0]
Fix with cubic

Comment thread helpers/find_shot.py
return {
"source_sha256": index["sha256"],
"candidates": sorted(
results, key=lambda r: (-r["inliers"], r["reprojection_px"] or 1e9)

@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 homography fits perfectly, reprojection_px is 0.0, and 0.0 or 1e9 evaluates to 1e9 because 0.0 is falsy. Among candidates tied on inlier count, the best (zero-error) fit is therefore ranked last instead of first. Treat only None as the missing value.

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 74:

<comment>When a homography fits perfectly, `reprojection_px` is `0.0`, and `0.0 or 1e9` evaluates to `1e9` because 0.0 is falsy. Among candidates tied on inlier count, the best (zero-error) fit is therefore ranked last instead of first. Treat only `None` as the missing value.</comment>

<file context>
@@ -0,0 +1,95 @@
+    return {
+        "source_sha256": index["sha256"],
+        "candidates": sorted(
+            results, key=lambda r: (-r["inliers"], r["reprojection_px"] or 1e9)
+        )[:limit],
+        "limit": "Visual candidates need native-frame and semantic review; coarse sampling cannot certify exact action timing",
</file context>
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