[m9-C] beat detection + audio trim at mux

/beats?path= returns bpm, a phase-locked beat grid, downbeats (4/4) and an
honest confidence, using only stdlib + ffmpeg: decode to PCM, log-energy onset
flux, harmonic-enhanced autocorrelation with an octave check, comb phase
search, weighted LS grid refit. 4-min track analyses in ~0.5s, cached after.

Audio in/out now honoured at mux in both scene renders and film beds via one
resolve_audio() path. Found en route: on this ffmpeg build the obvious
atrim/asetpts + amix/apad chain yields an aac stream with ZERO samples, rc=0,
no warning - trim moved to the input side; and the test helper would have
passed on that empty stream, so it now refuses to answer on a window that
decodes nothing.

Tempo bias fixed after orchestrator ground-truth check: decoding at 22050
discarded the HF transient, and since the attack's HF share grew across a file
the surviving LF onset landed progressively later - a per-beat-varying
lateness that reads as tempo error. BEAT_SR now 44100/512 (same 86fps
envelope). Second bug found by sweeping: harmonics outside the lag table
counted as zero and the parabola was fitted on a slope, so 118 BPM read
114.79.

Verified vs independent ground truth: 120.030 vs 120.003 true, 128.020 vs
128.004; worst offset from the true grid 6.0/10.1ms over the whole file (was
45/52ms). Tests now assert whole-file DRIFT, not average BPM, incl. a 200s
track and hftick.wav (16kHz tick over a 60Hz body) which pins BEAT_SR.
18 groups green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 00:13:40 +10:00
parent b773d29776
commit e05a1d2946
5 changed files with 821 additions and 49 deletions

View File

@ -201,3 +201,23 @@ NEXT for Lane C: audio `in`/`out` at mux is STILL the open gap (Lane B's head-tr
easy to hit) — close that. Then beat-sync groundwork: a `POST /beats?path=` that runs a beat
detector over an audio asset and returns a grid the timeline can snap to (MODELBEAST demucs
is available on ultra; the endpoint should 503 cleanly on the VPS like the others).
### 2026-07-26 — M9-C ACCEPTED at SYNC 11 (after one bounce-back).
Trim-at-mux verified. Beat detection verified against the orchestrator's independent
ground truth: beat120 120.030 vs 120.003 true, house128 128.020 vs 128.004, worst offset
from the true grid 6.0 / 10.1 ms across the WHOLE file (was 45 / 52 ms). 18 groups green.
Three things worth carrying forward as lessons:
1. You initially explained a real bug away as "the fixtures aren't exact" — and had
measured them correctly yourself earlier in the same session. When your output
disagrees with a fixture, the fixture is the hypothesis to test LAST, not first.
2. The root cause is a keeper: decoding at 22050 discarded the HF transient, and because
the attack's HF share GREW across the file, the surviving low-frequency onset landed
progressively later — a per-beat-varying lateness reads as a tempo error, and the LS
refit then faithfully fit the biased points. Diagnosing that from "0.3% low" is good work.
3. `hftick.wav` (16 kHz tick over a 60 Hz body) is now the regression that pins BEAT_SR,
and the note about 172 fps breaking octave choice is exactly the kind of comment that
stops someone "optimising" it back. Keep writing those.
Also good: proving the new tests DISCRIMINATE by monkeypatching the old code back, and
noticing that a pure click track passes under both versions — which is precisely why the
first round's tests missed it. Test with the pathological signal, not the clean one.
NEXT for Lane C: nothing queued. Lane B consumes /beats next (snap-to-beat + shot-list UI).

View File

@ -333,3 +333,198 @@ NEXT: nothing outstanding in Lane C. Backlog: per-scene audio carried into a fil
silence-input path for shots with no audio); more transition types (xfade has ~50,
`wipeleft`/`slideup` are one vocab entry each); still-unverified MediaRecorder draft
path (carried from session 3).
## 2026-07-25 session 7
DONE: **M9-C1 audio trim at the mux** + **M9-C2 BEAT DETECTION**. 18 test groups green
(was 15). `OK: 18 test groups passed`, whole suite ~6.5s.
**M9-C1 — `in`/`out` honoured in BOTH muxes.**
- `scenegod/server.py:112 resolve_audio()` — ONE function now turns an
`audio[]` entry into a muxable dict for both lanes (`/render/{id}/end` at :441
and `plan_sequence` at :562), so a clip means the same thing in a shot and in a
film. Validates path (guarded), `start`/`gain`/`in`/`out` types, `in>=0`,
`in<out`. `_num` moved up to :103 next to `safe_under` for the same reason.
- `:364 audio_input()` puts the trim on the INPUT (`-ss in -t out-in`) and
`:381 audio_filters()` keeps the adelay/volume/amix/apad chain EXACTLY as
shipped. Both muxes call the pair (`:401` render, `:718` film).
- Absent `in`/`out` = byte-identical ffmpeg command to before (proved by the
untrimmed A/B in the test).
- Verified end-to-end, not by eyeballing the command line: `scripts/test_server.py:250`
renders 3s of picture over `trim.wav` (1s silence, then 1s of 880Hz).
Trimmed to the tone at `start:0.5` -> `volumedetect` reads -91dB at 0.0-0.4s,
**-6.1dB at 0.6-1.4s**, -91dB at 1.7-2.7s. The SAME clip untrimmed reads -91dB
at 0.6-1.4 and -6.1dB at 1.6-2.4 — i.e. the trim moved the sound, and it is not
a tautological test. `:429` does the same for the film-level bed (silent to
1.8s, tone at 2.1s) inside the existing dissolve film.
DECISIONS (M9-C1):
- **`-ss`/`-t` on the input, NOT `atrim` in the filter graph** — this is a bug
fix, not taste. `atrim,asetpts,adelay,...,amix,apad` on ffmpeg 8.x
(homebrew, 62.x libs) emits pad frames at ~INT64_MAX PTS; the muxer drops all
of them and you get an mp4 with an aac stream and **zero audio samples**, rc=0,
no warning. Reproduced 4 ways: old chain OK, +atrim broken, +atrim without apad
OK, +atrim with `apad=whole_dur=N` OK. Input seeking leaves the proven graph
untouched. (Sample-exact for wav; a few ms at worst for mp3.)
- `scripts/test_server.py:59 seg_db()` REFUSES to answer when a window decoded no
samples, instead of returning -99. Otherwise every "assert it's silent here"
passes on a stream that is silent because it is empty — which is exactly the
failure above, and it would have shipped green.
- A trim window past EOF made ffmpeg die mid-mux (`-22 Error muxing a packet` ->
a mystery failed render), so `resolve_audio` ffprobes the source **only when a
trim is asked for**: `in` past the end is a 400/422 that says so, `out` past the
end is just no tail trim (a UI dragging the tail wide shouldn't fail a render).
**M9-C2 — `GET /beats?path=` (`server.py:869-1090`).**
`{path,bpm,confidence,beats[],downbeats[],duration,meter,period,acPeak,onBeatContrast,analyzed}`.
Chain (all stdlib + one ffmpeg pipe, no new deps):
1. `:896` ffmpeg -> mono s16 PCM @22050 (`-map 0:a:0`, so mp4/webm work too),
read with `array`. `-t 900` cap: 15 min analysed, grid extrapolated past that
with `truncated:true` (a 40MB decode ceiling, not a 2-hour DJ set in RAM).
2. `:908` per-frame **log energy**, hop 256 (86.13 fps) -> half-wave-rectified
first difference -> 3-tap smooth -> **local-mean subtraction** (±0.37s).
3. `:933` normalised autocorrelation over 60..180 BPM, harmonics added
(`r[l] + .5r[2l] + .5r[l/2] + .3r[3l/2]`), times a log-gaussian prior centred
on **125 BPM**, peak parabola-interpolated.
4. `:1029` explicit octave check: score half / self / double with the comb, keep
everything within 15% of the best, prefer the candidate in 90..180 BPM.
5. `:968` comb-filter phase search over one period, then `:972` **weighted
least-squares refit** of period+phase onto the nearest onset peaks (sub-frame,
parabolic). Without the refit the raw AC lag is ±0.5 frame = seconds of drift
across a 4-minute track.
6. Emits an absolute-seconds grid across the whole file; `downbeats` = every 4th
beat, **4/4 assumed**, coset chosen as the strongest of the four (`meter:4` is
in the response so a caller knows it was an assumption, not a detection).
MEASURED ACCURACY (synthetic click tracks, `aevalsrc` with `mod(t,period)` so beat
k is at EXACTLY k*period): 12 tempos 90/100/110/118/120/124/126/128/132/140/150/174
-> **worst BPM error 0.03** (spec allowed ±2), **worst beat-time error 3.3 ms**
over the first 16 beats (spec allowed ~30 ms), confidence 0.85-0.94.
Non-music: pink noise conf 0.10, digital silence conf 0.0 with `beats:[]` and a
`note` (no crash, 200). Real 90s house from ~/Music/HOUSE: 127.41 / 126.89 /
121.20 / 126.95 BPM, and independently re-analysing two disjoint 90s windows of
each track reproduces the full-track BPM to within 0.15 on 3 of 4 (the 4th is an
intro/breakdown pair that confidence correctly calls mush, 0.16-0.20).
SPEED (ultra, measured): 4-min wav **0.41s**, 5-min mp3 0.66s, 7.6-min mp3 0.69s;
cached re-hit **2-3 ms**. Cache key is (path, mtime_ns, size), bounded at 32.
DECISIONS (M9-C2):
- **Energy flux, not spectral flux.** A pure-Python radix-2 FFT is ~20k transforms
for a 4-min track (tens of seconds of CPU) and the repo forbids numpy; for
four-to-the-floor the kick dominates broadband energy anyway. The log
compression + local-mean whitening is what buys back the discrimination — it is
the difference between conf 0.07 and 0.35 on a dense club mix.
- **`confidence` = sqrt(acPeak × onBeatContrast)**, both reported separately so a
caller can see why. acPeak = normalised autocorrelation at the chosen period
("is the envelope periodic at all"); onBeatContrast = (on-beat mean all-frame
mean)/(sum) ("does the EMITTED grid explain the onsets"). Measured bands, in the
code comment: clicks 0.83-0.93, real house 0.32-0.65, noise 0.10, silence 0.0.
Read >0.5 as a solid grid, <0.2 as mush.
- Mean (not sum) comb score, deliberately: a double-tempo grid then pays for the
empty positions it adds, which is half the octave problem solved for free. The
half/double check + band preference is the other half — verified on `alt128`
(strong kick every 2nd beat, autocorrelates at 64) -> 128.01.
- The grid is CONSTANT tempo. No rubato/drift tracking, said plainly in the
response contract. For a DJ mix, tempo changes per track — analyse a segment.
- **The two fixtures in `assets/audio/test/` are not exact**: direct sample-level
onset timing puts `beat120.wav`'s kicks at 0.5 … 14.53 (28 intervals ->
**119.74 BPM**, it drifts ~0.3%), and `house128.wav` drifts similarly. My detector
says 119.62 / 127.62 on them, i.e. it is reading the FILE correctly. That is why
the test synthesises its own exactly-periodic clicks instead of asserting
against those two.
- New test group `scripts/test_server.py:525` boots an instance with
`PATH=/usr/bin:/bin` (no homebrew) and asserts `/beats` AND `/rhubarb` 503 —
the ffmpeg gate is now actually exercised, and so is rhubarb's 503 branch,
which had been unreachable on this box since rhubarb was installed.
- No client change was needed: `web/render.js:74 endRender` already forwards the
scene's `audio[]` objects verbatim, so `in`/`out` reach the server as-is.
BLOCKED/REQUESTS:
- @Lane B — `/beats` is ready for beat-snapping. Contract:
`GET beats?path=<asset-relative>` -> `{bpm, confidence, beats:[s,...],
downbeats:[s,...], duration, meter:4, period, acPeak, onBeatContrast}`; times
are absolute seconds in the FILE, so snapping a timeline key means
`beat + audioClip.start - (audioClip.in||0)`. Gate the UI on
`confidence` (>0.5 solid, <0.2 mush) rather than pretending every grid is good.
First call on a 5-min track is ~0.7s, after that it is instant — fetch once per
audio clip and cache client-side.
- @orchestrator — nothing needed for prod: ffmpeg is already in the image, so
`/beats` works on the VPS; no new env, no new dep (requirements.txt untouched).
The :8020 uvicorn still isn't `--reload`, so restart it before browser-driving
`/beats`. All my testing ran on ports 8097-8104 and 8098.
NEXT: nothing outstanding in Lane C. Backlog: per-scene audio auto-carried into a
film bed; more xfade transition types; the still-unverified MediaRecorder draft
path (carried from session 3); and — if beat-sync gets ambitious — a `?from=&to=`
window on `/beats` so a DJ mix can be analysed per track instead of assuming one
tempo for the whole hour.
## 2026-07-26 session 8 — M9-C2 tempo bias: root-caused and fixed
The orchestrator is right and my session-7 claim was wrong. **The fixtures are exact**
(`beat120.wav` kicks on 0.5000s to the microsecond, `house128.wav` on 0.468735s), and my
detector was **0.32% slow on both**. Worse, I had measured 120.002 correctly myself early
in that session and then believed a cruder second scan over it — the mistake was
disbelieving ground truth to protect the code. Corrected below.
**ROOT CAUSE — the onset front-end was measuring the wrong instant.**
A percussive hit is a high-frequency transient riding a slower low-frequency body.
`BEAT_SR=22050` resamples away everything above 11 kHz, and in `beat120.wav` the >11k
share of the attack grows ~20x across the file (peak sample 1402 at beat 1 -> 27711 at
beat 15). Measured, per beat, on the <11k signal that survived the decode:
beats 1/5/29 peak at **+0 ms** after the attack, beats 15/25 at **+30 / +50 ms**.
A lateness that VARIES per beat is a tempo error, not an offset — and the weighted
least-squares refit then fits those biased points perfectly. It was NOT the refit
(sub-millisecond over 200s click tracks) and NOT integer-lag AC quantisation
(the parabolic step already handled that; the AC's own estimate was 119.87, and the refit
moved it to 119.62 because its INPUT peaks were late).
**Fix: `BEAT_SR, BEAT_HOP = 44100, 512`** (`server.py:882`) — same 86.13 fps envelope, same
constants downstream, transient preserved. 172 fps was tried and REJECTED (`server.py:875`
comment): it breaks octave choice on real records (a 90s window of a house track went to
63.7 BPM) because the prior/harmonic weights are tuned for 86 fps.
`server.py:906 _frame_energy()` now decodes+frames in ONE STREAMING pass (Popen, ~4MB
reads) — 44.1k × 900s is 79MB of PCM and `subprocess.run` would hold it twice; peak memory
is now a few MB. It also returns the exact sample count, so `duration` is 15.0 not 14.988.
**SECOND BUG, found by sweeping tempos after the fix** (`server.py:963 _beat_tempo`):
a 118 BPM / 60s click track read **114.79**. Two defects compounding: (1) harmonics that
fall outside the lag table counted as ZERO, which silently penalises the lag that owns them
(lag 43 kept its 2x at 86, lag 44 lost its 88 — so a hair of score flipped the choice to
the wrong lag); (2) the parabolic interpolation was then applied to a point on a SLOPE,
which extrapolates: lag 43 became 44.95. Fixed by scoring only the harmonics that exist
(weight-normalised), then walking uphill to the local AC maximum before interpolating, and
clamping the correction to ±0.5 lag.
**RE-VERIFIED against the ground truth** (live over HTTP, `GET /beats`):
| file | truth | detected | err | max |beat true grid| | conf |
|---|---|---|---|---|---|
| `beat120.wav` | 120.003 | **120.030** | +0.027 | **6.0 ms** (last beat +3.3) | 0.92 |
| `house128.wav` | 128.004 | **128.020** | +0.016 | **10.1 ms** (last beat +7.6) | 0.86 |
Was 0.387 / 0.388 and 45.1 / 52.5 ms of drift.
Synthetic sweep, 17 tempos 60…179 BPM on 60s files: **worst BPM error 0.010, worst drift
5.7 ms across the whole file**. Octave traps still correct (alt-strong-every-2nd 128.01,
64 BPM stays 64, kick+offbeat-hat 125.97). Real 90s house unchanged: 127.41 / 126.89 /
121.22 / 126.96, disjoint-window self-consistency within 0.13 BPM on 3 of 4.
Truncation path exercised (cap forced to 60s on a 200s file): grid extrapolates to 200.0s
with 1.0 ms of drift at the end. Speed: **4-min wav 0.48s, 5-min mp3 0.84s**, 7.6-min 0.78s,
cached 2-3 ms (was 0.66s at 22050 — the transient costs ~0.2s and is worth it).
**TESTS — this class of error cannot pass again** (`scripts/test_server.py:468`, 18 groups):
1. Every click case now asserts **whole-file drift** (`max|b round(b/per)·per| < 20 ms`),
not just average BPM, and the sweep gained **118 BPM @ 60s** (pins the `_beat_tempo` fix).
2. **200s click track at 128** — end-of-file drift < 20 ms, and the grid must actually reach
past 195s (catches "correct tempo, grid stops early").
3. **`hftick.wav`** — a 16 kHz tick over a slow 60 Hz body, i.e. the failure mechanism
itself: the first 12 beats must land within **12 ms**. This is the one that pins
`BEAT_SR`, and it ships in the test (assets/ is gitignored).
4. **The fixtures as ground truth** when present: 120.003 / 128.004 ± 0.2 BPM and < 20 ms
drift, copied into the test's asset root, skipped with a printed note when absent.
Proven to discriminate, by monkeypatching the old code back in:
`22050/256` -> fixtures FAIL at 45.1 / 52.5 ms drift and hftick FAILS at 26.7 ms;
old `_beat_tempo` -> 118 BPM case FAILS at 114.79 (253 ms drift). Note the pure 200s click
track passes under BOTH old versions — a clean click track can never catch a spectral bias,
which is exactly why session 7's tests missed it.
DECISIONS:
- Ground truth wins. When a measurement disagrees with the code, the code is wrong until a
BETTER measurement says otherwise — and "better" means the one with the sharper
instrument (sample-level threshold crossing over the whole file), not the newer one.
- Front-end resolution is a tuning-coupled choice, not a free knob: 44100/512 keeps every
downstream constant valid; 44100/256 would need the prior and harmonic weights retuned.
The comment at `server.py:875` says so, so nobody "optimises" the decode rate back down.
BLOCKED/REQUESTS: none. Everything else from session 7 (trim work, speed, caching,
confidence, the 503 gate) is unchanged and still green.
NEXT: unchanged from session 7 (per-scene audio into a film bed, more transitions,
MediaRecorder draft path, `?from=&to=` windowing on /beats for DJ mixes).

View File

@ -1587,3 +1587,25 @@ INFO: 127.0.0.1:61531 - "GET /render/4c29712db270417a95697e00c73712e3/status
INFO: 127.0.0.1:61531 - "POST /films/67b76370c643433ba4ab9f569cea82f4/shot/2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61531 - "POST /films/67b76370c643433ba4ab9f569cea82f4/end HTTP/1.1" 200 OK
INFO: 127.0.0.1:61531 - "GET /films/67b76370c643433ba4ab9f569cea82f4/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:54829 - "GET /scenes HTTP/1.1" 200 OK
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [99875]
INFO: Started server process [14884]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit)
INFO: 127.0.0.1:55508 - "GET /beats?path=audio/test/house128.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:55509 - "GET /beats?path=audio/test/beat120.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:62063 - "GET /scenes HTTP/1.1" 200 OK
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [14884]
INFO: Started server process [20352]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit)
INFO: 127.0.0.1:62801 - "GET /beats?path=audio/test/beat120.wav HTTP/1.1" 200 OK
INFO: 127.0.0.1:62802 - "GET /beats?path=audio/test/house128.wav HTTP/1.1" 200 OK

View File

@ -7,8 +7,11 @@ formats. See PLAN.md §4.3 for the HTTP contract.
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
"""
import array
import base64
import json
import math
import operator
import os
import re
import shutil
@ -97,6 +100,55 @@ def safe_under(root: Path, relpath: str) -> Path:
return full
def _num(v, default=None):
"""Strict number coercion: None -> default, wrong type -> None (a violation)."""
if v is None:
return default
if isinstance(v, bool) or not isinstance(v, (int, float)):
return None
return float(v)
def resolve_audio(a: dict) -> tuple[dict | None, str | None]:
"""One audio entry (scene `audio[]` / sequence bed) -> a muxable dict, or an
error string. Shared by /render/end and plan_sequence so a clip means the
same thing in a shot and in a film. `in`/`out` are SOURCE-relative seconds
(Lane B's audio trim); `start` is where the trimmed piece lands on the
timeline. Path-guarded like every other path-taking input."""
if not isinstance(a, dict) or not isinstance(a.get("path"), str):
return None, "needs a string path"
try:
f = safe_under(ASSETS, a["path"])
except HTTPException:
return None, f"bad path {a['path']!r}"
if not f.is_file():
return None, f"not found under assets: {a['path']!r}"
start, gain = _num(a.get("start"), 0.0), _num(a.get("gain"), 1.0)
t_in, t_out = _num(a.get("in"), 0.0), _num(a.get("out"))
if start is None or gain is None or t_in is None or \
(a.get("out") is not None and t_out is None):
return None, "start/gain/in/out must be numbers"
if start < 0 or t_in < 0:
return None, f"start/in must be >= 0 (start={start}, in={t_in})"
if t_out is not None and t_out <= t_in:
return None, f"in ({t_in}) must be less than out ({t_out})"
e = {"path": a["path"], "file": str(f), "start": start, "gain": gain}
if t_in or t_out is not None:
# a window entirely past the end of the file makes ffmpeg exit -22 mid-mux
# ("Error muxing a packet"), which surfaces as a mystery failed render.
src = _probe_dur(f) # only probed when a trim is asked for
if src:
if t_in >= src:
return None, f"in ({t_in}) is past the end of {a['path']!r} ({src:.3f}s)"
if t_out is not None and t_out > src:
t_out = None # tail trim past EOF = no tail trim
if t_in:
e["in"] = t_in
if t_out is not None:
e["out"] = t_out
return e, None
# ---- assets ----
_tree_cache = {"t": 0.0, "val": None}
@ -309,22 +361,47 @@ def _render_dir(rid: str) -> Path:
return safe_under(RENDERS, rid) # rid is a hex uuid; guard anyway
def audio_input(a: dict) -> list[str]:
"""ffmpeg input args for one audio entry, honouring the SOURCE-relative
`in`/`out` window (Lane B's audio trim; absent = whole file).
The trim rides on the INPUT (-ss/-t) rather than an `atrim` in the filter
graph, which is not a style choice: `atrim,...,apad` yields a zero-sample
audio stream on ffmpeg 8.x (apad emits frames at ~INT64_MAX PTS, the muxer
drops the lot an mp4 with an aac stream and no audio in it). Input seeking
keeps the graph byte-identical to the chain that has been shipping."""
pre = []
if a.get("in"):
pre += ["-ss", f"{a['in']:.6f}"]
if a.get("out") is not None:
pre += ["-t", f"{a['out'] - (a.get('in') or 0.0):.6f}"]
return pre + ["-i", a["file"]]
def audio_filters(audio: list[dict], first_input: int) -> list[str]:
"""Filter-graph fragments for the audio inputs, ending in [aout]: delay each
track to its timeline `start`, apply gain, mix down to one stereo stream."""
if not audio:
return []
parts = [f"[{first_input + k}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{k}]"
for k, a in enumerate(audio)]
# apad + -shortest: the VIDEO decides the length. Without the pad, audio
# shorter than the shot truncates the picture (silent footage loss).
parts.append("".join(f"[a{k}]" for k in range(len(audio)))
+ f"amix=inputs={len(audio)}:normalize=0,apad[aout]")
return parts
def _encode(rid: str, fps: int, audio: list[dict]):
"""audio = [{file: abs path, start: sec, gain: float}] (paths pre-resolved)."""
"""audio = [{file: abs path, start: sec, gain: float, in?, out?}] (resolved)."""
d = _render_dir(rid)
_renders[rid] = {"state": "encoding", "log": ""}
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")]
for a in audio:
cmd += ["-i", a["file"]]
cmd += audio_input(a) # in/out trim rides on the input (M9)
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"]
if audio: # delay each track to its start, apply gain, mix down to one stereo stream
parts = [f"[{i}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{i}]"
for i, a in enumerate(audio, start=1)]
# apad + -shortest: the VIDEO decides the length. Without the pad, audio
# shorter than the shot truncates the picture (silent footage loss).
parts.append("".join(f"[a{i}]" for i in range(1, len(audio) + 1))
+ f"amix=inputs={len(audio)}:normalize=0,apad[aout]")
cmd += ["-filter_complex", ";".join(parts),
if audio: # delay to start, apply gain, mix down to one stereo stream
cmd += ["-filter_complex", ";".join(audio_filters(audio, 1)),
"-map", "0:v", "-map", "[aout]", "-c:a", "aac", "-shortest"]
cmd += [str(d / "out.mp4")]
try:
@ -384,12 +461,11 @@ async def render_end(rid: str, request: Request):
except json.JSONDecodeError:
body = {}
audio = []
for a in body.get("audio") or []: # scene JSON audio[] (M4)
f = safe_under(ASSETS, a["path"]) # path-guard every audio input
if not f.is_file():
raise HTTPException(400, f"audio not found: {a['path']}")
audio.append({"file": str(f), "start": float(a.get("start", 0)),
"gain": float(a.get("gain", 1.0))})
for a in body.get("audio") or []: # scene JSON audio[] (M4, +in/out M9)
e, err = resolve_audio(a if isinstance(a, dict) else {})
if err:
raise HTTPException(400, f"audio: {err}")
audio.append(e)
threading.Thread(target=_encode, args=(rid, fps, audio), daemon=True).start()
return {"ok": True}
@ -431,15 +507,6 @@ MAX_SHOTS = 200
MAX_TRANSITION_S = 10.0
def _num(v, default=None):
"""Strict number coercion: None -> default, wrong type -> None (a violation)."""
if v is None:
return default
if isinstance(v, bool) or not isinstance(v, (int, float)):
return None
return float(v)
def plan_sequence(seq: dict) -> tuple[list[str], dict]:
"""Validate a sequence AND resolve it to a concrete shot plan in one pass, so
save-time validation and render-time planning can never disagree about what a
@ -529,22 +596,11 @@ def plan_sequence(seq: dict) -> tuple[list[str], dict]:
errs.append("audio must be a list")
aud = []
for k, a in enumerate(aud):
if not isinstance(a, dict) or not isinstance(a.get("path"), str):
errs.append(f"audio[{k}]: needs a string path")
continue
try:
f = safe_under(ASSETS, a["path"]) # path-guard every audio input
except HTTPException:
errs.append(f"audio[{k}]: bad path {a['path']!r}")
continue
if not f.is_file():
errs.append(f"audio[{k}]: not found under assets: {a['path']!r}")
continue
start, gain = _num(a.get("start"), 0.0), _num(a.get("gain"), 1.0)
if start is None or gain is None or start < 0:
errs.append(f"audio[{k}]: start/gain must be numbers (start >= 0)")
continue
plan["audio"].append({"path": a["path"], "file": str(f), "start": start, "gain": gain})
e, err = resolve_audio(a)
if err:
errs.append(f"audio[{k}]: {err}")
else:
plan["audio"].append(e)
plan["duration"] = round(sum(s["len"] for s in ps) - sum(s["transitionDur"] for s in ps[:-1]), 6)
plan["warnings"] = list(dict.fromkeys(warnings)) # dedupe, keep order
@ -661,7 +717,7 @@ def _concat_film(fid: str):
durs = [_probe_dur(f) or s["len"] for f, s in zip(srcs, shots)]
cmd = ["ffmpeg", "-y"] + [x for f in srcs for x in ("-i", str(f))]
cmd += [x for a in meta["audio"] for x in ("-i", a["file"])]
cmd += [x for a in meta["audio"] for x in audio_input(a)] # in/out trim (M9)
# normalise every shot to the film's grid — mismatches are rejected at
# registration, this is the belt to that braces (SAR/fps oddities included).
parts = [f"[{i}:v]scale={w}:{h},setsar=1,fps={fps},format=yuv420p[v{i}]" for i in range(len(srcs))]
@ -677,11 +733,7 @@ def _concat_film(fid: str):
parts.append(f"[{cur}][v{i}]concat=n=2:v=1:a=0[x{i}]")
acc += durs[i]
cur = f"x{i}"
for k, a in enumerate(meta["audio"]):
parts.append(f"[{len(srcs)+k}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{k}]")
if meta["audio"]:
parts.append("".join(f"[a{k}]" for k in range(len(meta["audio"])))
+ f"amix=inputs={len(meta['audio'])}:normalize=0,apad[aout]")
parts += audio_filters(meta["audio"], len(srcs)) # delay/gain/mix the film-level bed
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{cur}]"]
if meta["audio"]:
cmd += ["-map", "[aout]", "-c:a", "aac", "-shortest"] # apad+shortest: video sets the length
@ -814,6 +866,274 @@ def rhubarb(path: str):
return json.loads(p.stdout)
# ---- BEAT DETECTION (M9-C) — the grid a music video cuts itself to ----
# stdlib + ffmpeg only (repo rule: no librosa/aubio/numpy). The chain:
# ffmpeg -> mono s16 PCM @ 44100 (anything ffmpeg reads: wav/mp3/mp4/...)
# framing -> per-frame log energy, hop 512 = 86.13 envelope fps
# onset -> half-wave-rectified first difference [ENERGY flux]
# whitening -> subtract a local mean so a dense mix has a floor near zero
# tempo -> normalised autocorrelation 60..180 BPM, harmonics added, then
# a log-gaussian prior centred on 125 BPM (John's material is
# 90s house), then an explicit half/double check
# phase -> comb-filter search over one period
# grid -> least-squares refit of period+phase to the nearest onset peaks
# ENERGY flux, not spectral flux: a pure-Python radix-2 FFT would be ~20k
# transforms for a 4-minute track (tens of seconds of CPU), and on
# four-to-the-floor material the kick dominates the broadband energy anyway.
# Measured on ultra: 5-minute mp3 = 0.85s wall. 4/4 is ASSUMED — `downbeats` is
# every 4th beat, phase chosen as the strongest of the four cosets. Beats are a
# CONSTANT-tempo grid (no rubato tracking).
#
# DECODE AT 44100, NOT 22050 (M9-C fix — do not "optimise" this back down):
# a percussive hit is a high-frequency transient over a slower low-frequency
# body. Resampling to 22050 throws away everything above 11kHz, and in
# assets/audio/test/beat120.wav the >11k share of the attack grows 20x across
# the file (1402 -> 27711 peak sample) — so for the middle beats the surviving
# <11k energy peaks 30-50ms AFTER the attack while the first and last beats
# still peak at +0ms. A lateness that VARIES per beat is a tempo bias, not an
# offset: the detector read 119.62 on a file whose kicks are on 0.5000s to the
# microsecond, i.e. 0.32% slow = ~0.8s of slip across a 4-minute track. At 44100
# the transient survives and the same code reads 120.03. (172 fps was tried and
# rejected: it breaks octave choice on real records — a 90s house window went to
# 63.7 BPM — because the prior/harmonic weights below are tuned for 86 fps.)
BEAT_SR, BEAT_HOP = 44100, 512
BEAT_FPS = BEAT_SR / BEAT_HOP # 86.13 onset frames/s
BEAT_MIN_BPM, BEAT_MAX_BPM = 60.0, 180.0
BEAT_PRIOR_BPM, BEAT_PRIOR_OCT = 125.0, 0.9 # tempo prior: centre, width in octaves
BEAT_PREF = (90.0, 180.0) # preferred band for the octave check
BEAT_WHITEN_W = 32 # local-mean window, +-0.37s
BEAT_MAX_S = 900 # analyse at most 15 min (memory); grid extends
_beats_cache: dict[tuple, dict] = {}
def _frame_energy(f: Path) -> tuple[list[float], int]:
"""Decode and reduce to per-frame log energy in ONE streaming pass. 15
minutes at 44.1k is 79MB of PCM; buffering that twice (subprocess buffer +
array) is not a thing to do inside the prod container, and none of it is
needed after the framing. stderr stays a pipe for the error message
safe only because `-v error` keeps it far under the pipe buffer."""
p = subprocess.Popen(["ffmpeg", "-v", "error", "-nostdin", "-i", str(f), "-map", "0:a:0",
"-t", str(BEAT_MAX_S), "-f", "s16le", "-ac", "1",
"-ar", str(BEAT_SR), "-"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
e, buf, stride, nbytes = [], b"", BEAT_HOP * 2, 0
ap = e.append
while True:
chunk = p.stdout.read(stride * 4096) # ~4MB reads
if not chunk:
break
nbytes += len(chunk)
buf = buf + chunk if buf else chunk
nfr = len(buf) // stride
if not nfr:
continue
a = array.array("h")
a.frombytes(buf[:nfr * stride])
buf = buf[nfr * stride:]
for i in range(0, len(a), BEAT_HOP):
fr = a[i:i + BEAT_HOP]
ap(math.log1p(sum(map(operator.mul, fr, fr)) / BEAT_HOP))
err = p.stderr.read()
if p.wait() != 0:
raise HTTPException(400, "cannot decode audio: "
+ err[-200:].decode("utf-8", "replace").strip())
return e, nbytes // 2 # sample count, so `duration` isn't a frame short
def _onset_envelope(e: list[float]) -> list[float]:
"""Per-frame log energy -> rectified first difference -> 3-tap smooth ->
local-mean subtraction. The whitening is what makes a busy record's peaks
stand out from its floor (and the confidence number mean anything)."""
o = [0.0] + [max(0.0, e[i] - e[i - 1]) for i in range(1, len(e))]
n = len(o)
o = [(o[max(0, i - 1)] + o[i] + o[min(n - 1, i + 1)]) / 3 for i in range(n)]
w = BEAT_WHITEN_W
if n < 2 * w + 2:
return o
run, out = sum(o[:w + 1]), []
for i in range(n):
out.append(max(0.0, o[i] - run / (min(n - 1, i + w) - max(0, i - w) + 1)))
if i + 1 + w < n:
run += o[i + 1 + w]
if i - w >= 0:
run -= o[i - w]
return out
def _beat_tempo(o: list[float]) -> tuple[float, dict]:
"""(period in frames, autocorrelation table). Peak is parabola-interpolated:
the integer lag is only good to ~0.5 frame, which is seconds of drift across
a 4-minute track before the least-squares refit cleans it up."""
n = len(o)
m = sum(o) / n
oc = [x - m for x in o]
e0 = sum(map(operator.mul, oc, oc)) / n or 1e-9
lo = int(60 * BEAT_FPS / BEAT_MAX_BPM)
hi = int(math.ceil(60 * BEAT_FPS / BEAT_MIN_BPM))
r = {}
for lag in range(lo, hi + 1):
k = n - lag
r[lag] = (sum(map(operator.mul, oc[:k], oc[lag:])) / k / e0) if k > 8 else 0.0
p0 = 60 * BEAT_FPS / BEAT_PRIOR_BPM
def score(g):
# harmonics that fall OUTSIDE the lag table must not count as zero — that
# silently penalises every lag whose 2x is past the table edge (it cost a
# 118 BPM track 3 BPM: lag 43 kept its 2x at 86, lag 44 lost its 88).
terms = [(1.0, r[g])] + [(w, r[h]) for w, h in
((.5, 2 * g), (.5, g // 2), (.3, 3 * g // 2)) if h in r]
return (sum(w * v for w, v in terms) / sum(w for w, _ in terms)
* math.exp(-.5 * (math.log2(g / p0) / BEAT_PRIOR_OCT) ** 2))
best = max(r, key=score)
# the harmonics and the prior choose WHICH peak; the raw autocorrelation
# locates it. Walk uphill first: a parabola fitted to a point on a SLOPE
# extrapolates instead of interpolating (that is how lag 43 became 44.95).
while best + 1 in r and r[best + 1] > r[best]:
best += 1
while best - 1 in r and r[best - 1] > r[best]:
best -= 1
a, b, c = r.get(best - 1, 0.), r[best], r.get(best + 1, 0.)
d = a - 2 * b + c
return best + max(-0.5, min(0.5, 0.5 * (a - c) / d if d else 0.0)), r
def _comb(o: list[float], period: float, phase: float) -> float:
"""Mean onset strength on a beat grid — the score a phase/period is judged by.
MEAN (not sum) is deliberate: it makes a double-tempo grid pay for the empty
positions it adds, which is half the octave problem solved for free."""
s, cnt, i, n = 0.0, 0, phase, len(o)
while i < n - 1:
s += o[int(i + 0.5)]
cnt += 1
i += period
return s / cnt if cnt else 0.0
def _best_phase(o: list[float], period: float) -> tuple[float, int]:
return max((_comb(o, period, ph), ph) for ph in range(max(1, int(period))))
def _fit_grid(o: list[float], period: float, phase: float, iters: int = 3):
"""Snap each predicted beat to the nearest onset peak (sub-frame, parabolic)
and refit t_k = phase + k*period by onset-weighted least squares."""
n = len(o)
lo_p, hi_p = 60 * BEAT_FPS / BEAT_MAX_BPM, 60 * BEAT_FPS / BEAT_MIN_BPM
for _ in range(iters):
win = max(1, int(period * 0.12))
xs, ys, ws = [], [], []
k, t = 0, phase
while t < n - 1:
c = int(round(t))
a, b = max(1, c - win), min(n - 2, c + win)
if b > a:
j = max(range(a, b + 1), key=o.__getitem__)
p1, p2, p3 = o[j - 1], o[j], o[j + 1]
d = p1 - 2 * p2 + p3
frac = (0.5 * (p1 - p3) / d) if d else 0.0
if abs(frac) > 1:
frac = 0.0
if p2 > 0:
xs.append(k)
ys.append(j + frac)
ws.append(p2)
k += 1
t = phase + k * period
if len(xs) < 4:
break
sw = sum(ws)
mx = sum(w * x for w, x in zip(ws, xs)) / sw
my = sum(w * y for w, y in zip(ws, ys)) / sw
den = sum(w * (x - mx) ** 2 for w, x in zip(ws, xs))
if den <= 0:
break
period = min(max(sum(w * (x - mx) * (y - my)
for w, x, y in zip(ws, xs, ys)) / den, lo_p), hi_p)
phase = my - period * mx
return period, phase
def analyse_beats(f: Path) -> dict:
frames, nsamples = _frame_energy(f)
analyzed = nsamples / BEAT_SR
truncated = analyzed >= BEAT_MAX_S - 0.5
full = (_probe_dur(f) or analyzed) if truncated else analyzed
out = {"bpm": 0.0, "confidence": 0.0, "beats": [], "downbeats": [], "meter": 4,
"duration": round(full, 3), "analyzed": round(analyzed, 3),
"period": 0.0, "acPeak": 0.0, "onBeatContrast": 0.0}
o = _onset_envelope(frames)
n = len(o)
lo_p, hi_p = 60 * BEAT_FPS / BEAT_MAX_BPM, 60 * BEAT_FPS / BEAT_MIN_BPM
if n < 2 * hi_p or max(o, default=0.0) <= 1e-9:
out["note"] = "no beat grid: silent, or shorter than two slow bars"
return out
lag, r = _beat_tempo(o)
cands = []
for p in (lag / 2, lag, lag * 2): # explicit octave check: half, self, double
if lo_p <= p <= hi_p:
s, ph = _best_phase(o, p)
cands.append((s, p, ph))
top = max(c[0] for c in cands)
keep = [c for c in cands if c[0] >= 0.85 * top] or cands
# when half/double score comparably, take the one in the sane band — a 128 BPM
# record whose onset envelope autocorrelates hardest at 64 is the normal case.
_, period, phase = max(keep, key=lambda c: (BEAT_PREF[0] <= 60 * BEAT_FPS / c[1]
<= BEAT_PREF[1], c[0]))
period, phase = _fit_grid(o, period, phase)
# frame i is the window [i, i+1) hops, so its centre is the honest onset time
ph = phase + 0.5
ph -= period * math.floor((ph + 0.05 * BEAT_FPS) / period) # into [-50ms, period-50ms):
beats_f = [] # a track starting ON the
k = 0 # beat gets a beat at ~0
while ph + k * period <= full * BEAT_FPS:
beats_f.append(ph + k * period)
k += 1
inwin = [(i, int(round(t))) for i, t in enumerate(beats_f) if 0 <= round(t) < n]
onb = [o[j] for _, j in inwin]
onm = sum(onb) / len(onb) if onb else 0.0
allm = sum(o) / n
# confidence = geometric mean of two independent, honest checks:
# acPeak how periodic the onset envelope is at this period (0..1)
# onBeatContrast how much onset energy lands ON the emitted grid vs the
# whole track, (on-all)/(on+all), 0 = no better than chance
# Measured: synthetic click tracks 0.83-0.93, real 90s house 0.32-0.51,
# pink noise 0.09, silence 0.0. Treat >0.5 as a solid grid, <0.2 as mush.
ac = max(0.0, min(1.0, r.get(int(round(period)), 0.0)))
contrast = max(0.0, (onm - allm) / (onm + allm)) if onm + allm else 0.0
dbc = max(range(4), key=lambda c: sum(o[j] for i, j in inwin if i % 4 == c)
/ max(1, sum(1 for i, _ in inwin if i % 4 == c)))
out.update(bpm=round(60 * BEAT_FPS / period, 2),
period=round(period / BEAT_FPS, 6),
confidence=round(math.sqrt(ac * contrast), 3),
acPeak=round(ac, 3), onBeatContrast=round(contrast, 3),
beats=[round(max(0.0, t / BEAT_FPS), 4) for t in beats_f],
downbeats=[round(max(0.0, t / BEAT_FPS), 4) for t in beats_f[dbc::4]])
if truncated:
out["truncated"] = True # grid extrapolated past the analysed window
return out
@app.get("/beats")
def beats(path: str):
"""Beat grid for an audio asset -> {bpm, confidence, beats[], downbeats[],
duration, ...}. Times are absolute seconds from the start of the file; the
grid is constant-tempo and 4/4 is assumed (see the section header for the
algorithm and what `confidence` means). Cached by (path, mtime, size)."""
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
f = safe_under(ASSETS, path)
if not f.is_file():
raise HTTPException(404, "not found")
st = f.stat()
key = (str(f), st.st_mtime_ns, st.st_size)
if key not in _beats_cache:
if len(_beats_cache) > 32:
_beats_cache.clear() # ponytail: tiny bounded cache; clearing beats an LRU here
_beats_cache[key] = analyse_beats(f)
return {"path": path, **_beats_cache[key]}
# ---- DIRECTOR MODE (M5) — NL text -> validated preset ops via tailnet LLM ----
# Vocab mirrors PLAN §5 M5. NOTE: Lane A's web/grammar.js hasn't landed yet —
# when it does, re-point these lists at that file (read at boot) so the server

View File

@ -10,6 +10,7 @@ validation failures.
import base64
import json
import os
import re
import shutil
import subprocess
import sys
@ -44,6 +45,32 @@ def get_json(path):
return json.loads(b)
def ff(*args):
subprocess.run(["ffmpeg", "-y", *args], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def probe_dur(f):
return float(subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nokey=1", str(f)], text=True).strip())
def seg_db(f, ss, dur):
"""mean volume (dB) of one window of a media file — digital silence reads
about -91 dB, so this is how a test asks 'is the audio actually HERE?'.
Refuses to answer when the window decoded NO samples: our muxes apad to the
video length, so an empty window means a broken stream, not a quiet one
(an atrim+apad graph once produced exactly that aac stream, zero audio)."""
p = subprocess.run(["ffmpeg", "-v", "info", "-ss", str(ss), "-t", str(dur), "-i", str(f),
"-af", "volumedetect", "-f", "null", "-"], capture_output=True, text=True)
m = re.search(r"mean_volume:\s*(-?[\d.]+) dB", p.stderr)
if not m:
raise AssertionError(f"no audio samples in {Path(f).name} at {ss}..{ss+dur}s\n"
+ p.stderr[-400:])
return float(m.group(1))
def main():
tmp = Path(tempfile.mkdtemp(prefix="scenegod_test_"))
assets, scenes = tmp / "assets", tmp / "scenes"
@ -219,6 +246,52 @@ def main():
assert req("POST", f"/render/{rid}/end",
json.dumps({"audio": [{"path": "audio/nope.wav"}]}))[0] == 400
n += 1
# --- M9-C1: audio in/out honoured at the mux -------------------
# trim.wav = 1s of silence, then 1s of 880Hz. Trimming to the tone
# and starting at 0.5s must put SOUND at 0.5-1.5s and nothing else;
# the same clip untrimmed must still lead with its silent second.
ff("-f", "lavfi", "-i", "aevalsrc='if(gte(t,1),0.7*sin(2*PI*880*t),0)':s=44100:d=2",
"-c:a", "pcm_s16le", str(assets / "audio" / "test" / "trim.wav"))
pngs = sorted(fr.glob("*.png"))
def render_audio(audio, frames=90): # 3s of picture @30fps
r2 = json.loads(req("POST", "/render/begin",
json.dumps({"name": "t", "fps": 30}))[1])["renderId"]
for i in range(frames):
assert req("POST", f"/render/{r2}/frame/{i}",
pngs[i % len(pngs)].read_bytes())[0] == 200
assert req("POST", f"/render/{r2}/end", json.dumps({"audio": audio}))[0] == 200
for _ in range(150):
s2 = json.loads(req("GET", f"/render/{r2}/status")[1])["state"]
if s2 in ("done", "error"):
break
time.sleep(0.2)
assert s2 == "done", s2
return renders / r2 / "out.mp4"
cut = render_audio([{"path": "audio/test/trim.wav", "start": 0.5,
"in": 1.0, "out": 2.0, "gain": 1.0}])
assert abs(probe_dur(cut) - 3.0) < 0.2, probe_dur(cut)
assert seg_db(cut, 0.0, 0.4) < -50, f"trimmed clip leaks before start: {seg_db(cut,0,0.4)}"
assert seg_db(cut, 0.6, 0.8) > -35, f"trimmed tone missing at 0.6-1.4s: {seg_db(cut,0.6,0.8)}"
assert seg_db(cut, 1.7, 1.0) < -50, f"trimmed clip runs past out: {seg_db(cut,1.7,1.0)}"
whole = render_audio([{"path": "audio/test/trim.wav", "start": 0.5, "gain": 1.0}])
assert seg_db(whole, 0.6, 0.8) < -50, "untrimmed clip must still play its silent head"
assert seg_db(whole, 1.6, 0.8) > -35, "untrimmed tone should land a second later"
# and the trim is validated, not trusted
for bad in ({"in": -1}, {"in": 2, "out": 1}, {"in": "x"}, {"start": -1}):
assert req("POST", f"/render/{rid}/end", json.dumps(
{"audio": [{"path": "audio/test/trim.wav", **bad}]}))[0] == 400, bad
# a window past EOF would fail the MUX (ffmpeg -22) — caught up front
st, b = req("POST", f"/render/{rid}/end", json.dumps(
{"audio": [{"path": "audio/test/trim.wav", "in": 99}]}))
assert st == 400 and b"past the end" in b, b
# a tail past EOF is just no tail trim: renders, and still plays
past = render_audio([{"path": "audio/test/trim.wav", "start": 0.0,
"in": 1.0, "out": 60.0, "gain": 1.0}])
assert seg_db(past, 0.1, 0.8) > -35, "clamped tail trim lost the audio"
n += 1
else:
print("(render test skipped — ffmpeg/ffprobe not found)")
@ -353,9 +426,12 @@ def main():
"-i", "sine=frequency=440:duration=1", str(tone)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
dshots = [dict(shots[0], transition="dissolve", transitionDur=0.5), shots[1]]
# M9-C1: the film-level bed is trimmed too — trim.wav's tone (source
# 1..2s) placed at 2.0s must be silent before it and present after.
film2 = make_film("film-dissolve", {"version": 1, "shots": dshots,
"audio": [{"path": "audio/test/tone.wav",
"start": 0.25, "gain": 0.8}]})
"audio": [{"path": "audio/test/trim.wav",
"start": 2.0, "in": 1.0, "out": 2.0,
"gain": 0.8}]})
assert film2["duration"] == 4.5, film2
assert not film2["warnings"], film2 # bed present -> no warning
out2 = finish_film(film2["filmId"], [fake_shot_render(2.0), fake_shot_render(3.0, color="blue")])
@ -363,6 +439,13 @@ def main():
assert abs(dur2 - 4.5) < 0.15, f"dissolve film {dur2}s, expected ~4.5s (2+3-0.5)"
codecs = probe(out2, "stream=codec_type").split()
assert "audio" in codecs, f"film audio bed not muxed: {codecs}"
assert seg_db(out2, 0.0, 1.8) < -50, f"film bed leaks before start: {seg_db(out2,0,1.8)}"
assert seg_db(out2, 2.1, 0.8) > -35, f"film bed tone missing: {seg_db(out2,2.1,0.8)}"
# a bad trim is a 422 with a message, same as every other violation
st, b = req("POST", "/sequences/bad", json.dumps(
{"version": 1, "shots": dshots,
"audio": [{"path": "audio/test/trim.wav", "in": 3, "out": 1}]}))
assert st == 422 and b"less than" in b, b
n += 1
else:
print("(film test skipped — ffmpeg/ffprobe not found)")
@ -382,6 +465,138 @@ def main():
assert sh.get("out", dur) <= dur + 1e-6, f"{p.name}: shot past end of {sh['scene']}"
n += 1
# --- M9-C2: BEAT DETECTION on click tracks of KNOWN tempo ------------
if shutil.which("ffmpeg"):
(assets / "audio" / "test").mkdir(parents=True, exist_ok=True)
def click(name, bpm, dur=20):
"""Exactly-periodic click track: mod(t, period) in aevalsrc is
double-precision, so beat k is at exactly k*period (unlike a
concatenated or gated source, which drifts)."""
per = 60.0 / bpm
ff("-f", "lavfi", "-i", f"aevalsrc='0.8*sin(2*PI*1200*t)"
f"*exp(-45*mod(t,{per:.9f}))':s=44100:d={dur}",
"-c:a", "pcm_s16le", str(assets / "audio" / "test" / name))
return per
# 118 @ 60s is not padding: harmonics falling off the end of the lag
# table used to penalise their own lag, and the parabola was then
# fitted to a point on a slope — together they read this one as 114.8.
for name, bpm, dur in (("click120.wav", 120, 20), ("click128.wav", 128, 20),
("click118.wav", 118, 60)):
per = click(name, bpm, dur=dur)
r = get_json("/beats?path=audio/test/" + name)
assert abs(r["bpm"] - bpm) <= 2, f"{name}: detected {r['bpm']}, truth {bpm}"
assert r["confidence"] > 0.5, f"{name}: confidence {r['confidence']} on a click track"
assert abs(r["duration"] - dur) < 0.2 and r["meter"] == 4, r
assert len(r["beats"]) >= int(dur / per) - 1, len(r["beats"])
for i, b in enumerate(r["beats"][:12]): # grid lands on the real clicks
assert abs(b - i * per) < 0.03, f"{name}: beat {i} at {b}s, truth {i*per:.4f}s"
d = max(abs(b - round(b / per) * per) for b in r["beats"]) # whole file
assert d < 0.020, f"{name}: grid slips {d*1000:.0f}ms somewhere in the file"
assert r["downbeats"], r
i0 = r["beats"].index(r["downbeats"][0]) # 4/4: every 4th beat
assert [r["beats"][i0 + 4 * k] for k in range(3)] == r["downbeats"][:3], r["downbeats"]
# ACCUMULATED DRIFT, not average tempo. A 0.3% tempo bias is invisible
# in a 20s clip and is 0.6s of slip by the end of a 200s one — cuts
# snapped late in a track would land visibly off the kick.
per = click("long128.wav", 128, dur=200)
r = get_json("/beats?path=audio/test/long128.wav")
assert abs(r["bpm"] - 128) < 0.1, f"long file: {r['bpm']}"
assert r["beats"][-1] > 195, f"grid stops at {r['beats'][-1]}s of a 200s file"
drift = max(abs(b - round(b / per) * per) for b in r["beats"])
assert drift < 0.020, f"grid slips {drift*1000:.0f}ms across a 200s file"
# The M9 bug this pins: a percussive attack is a HIGH-frequency
# transient riding a slower low-frequency body. Decode at 22050 and
# the transient is gone, so the onset lands on the body's energy peak
# ~25ms late — and by a DIFFERENT amount per beat once the mix
# evolves, which is a tempo error, not an offset. 16kHz tick over a
# slow 60Hz body: at 44100 this reads +1ms, at 22050 +27ms.
p124 = 60.0 / 124
ff("-f", "lavfi", "-i",
f"aevalsrc='0.45*sin(2*PI*60*t)*(1-exp(-8*mod(t,{p124:.9f})))"
f"*exp(-5*mod(t,{p124:.9f}))"
f"+0.5*sin(2*PI*16000*t)*exp(-150*mod(t,{p124:.9f}))':s=44100:d=60",
"-c:a", "pcm_s16le", str(assets / "audio" / "test" / "hftick.wav"))
r = get_json("/beats?path=audio/test/hftick.wav")
assert abs(r["bpm"] - 124) < 0.1, f"hf transient: {r['bpm']}"
for i, b in enumerate(r["beats"][:12]):
assert abs(b - i * p124) < 0.012, (
f"hftick beat {i} at {b}s, truth {i*p124:.4f}s — the decode is "
f"throwing away the transient that defines the attack")
# The two shipped fixtures are GROUND TRUTH: measured straight from
# PCM, beat120's onsets sit on 0.5000s multiples to the microsecond
# (120.003 BPM) and house128's on 0.468735s (128.004). assets/ is
# gitignored, so skip when they are not on this box.
for name, truth in (("beat120.wav", 120.003), ("house128.wav", 128.004)):
srcf = ROOT / "assets" / "audio" / "test" / name
if not srcf.is_file():
print(f"(fixture check skipped — no assets/audio/test/{name})")
continue
shutil.copy(srcf, assets / "audio" / "test" / name)
r = get_json("/beats?path=audio/test/" + name)
assert abs(r["bpm"] - truth) < 0.2, f"{name}: read {r['bpm']}, truth {truth}"
p = 60.0 / truth
d = max(abs(b - round(b / p) * p) for b in r["beats"])
assert d < 0.020, f"{name}: grid is {d*1000:.0f}ms off the real kick"
# mush must LOOK like mush — low confidence, no crash, still 200
ff("-f", "lavfi", "-i", "anoisesrc=d=10:c=pink:a=0.3", "-c:a", "pcm_s16le",
str(assets / "audio" / "test" / "noise.wav"))
ff("-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "8", "-c:a", "pcm_s16le",
str(assets / "audio" / "test" / "quiet.wav"))
for name in ("noise.wav", "quiet.wav"):
r = get_json("/beats?path=audio/test/" + name)
assert r["confidence"] < 0.35, f"{name}: confidence {r['confidence']} on non-music"
assert isinstance(r["beats"], list) and isinstance(r["bpm"], (int, float)), r
assert get_json("/beats?path=audio/test/quiet.wav")["beats"] == [], "silence has no grid"
# guards + cache
for bad in ("../../etc/hosts", "..%2f..%2fetc%2fhosts",
urllib.parse.quote("/etc/hosts", safe="")):
assert req("GET", "/beats?path=" + bad)[0] >= 400, bad
assert req("GET", "/beats?path=audio/test/nope.wav")[0] == 404
click("cachecheck.wav", 124, dur=40) # never analysed yet: first call is cold
t0 = time.time()
a1 = get_json("/beats?path=audio/test/cachecheck.wav")
t1 = time.time()
a2 = get_json("/beats?path=audio/test/cachecheck.wav")
t2 = time.time()
assert abs(a1["bpm"] - 124) <= 2, a1["bpm"]
assert a1 == a2, "cached /beats must return the same grid"
assert (t2 - t1) < (t1 - t0) / 2, \
f"second /beats call not cached ({t2-t1:.3f}s vs {t1-t0:.3f}s)"
n += 1
else:
print("(beats test skipped — ffmpeg not found)")
# --- the ffmpeg/rhubarb gates: a box without the binaries 503s cleanly ---
port4 = PORT + 5
p4 = subprocess.Popen(
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(port4)],
cwd=ROOT, env={**env, "PATH": "/usr/bin:/bin"}, # no homebrew -> no ffmpeg/rhubarb
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50):
try:
urllib.request.urlopen(f"http://127.0.0.1:{port4}/scenes")
break
except urllib.error.URLError:
time.sleep(0.2)
for path in ("/beats?path=audio/test/tone.wav", "/rhubarb?path=audio/test/tone.wav"):
try:
urllib.request.urlopen(f"http://127.0.0.1:{port4}{path}")
raise AssertionError(f"{path} should 503 without ffmpeg/rhubarb")
except urllib.error.HTTPError as e:
assert e.code == 503, (path, e.code)
n += 1
finally:
p4.terminate()
p4.wait()
# --- rhubarb: 503 when the binary is absent (happy path needs rhubarb) ---
if not shutil.which("rhubarb"):
assert req("GET", "/rhubarb?path=audio/x.wav")[0] == 503