feat(cli): add normalize-audio to match one clip's loudness to another (#3306)

* feat(cli): add normalize-audio to match one clip's loudness to another

Measures two authored `<audio>` clips with FFmpeg's integrated EBU R128
loudness and writes the target's matching `data-volume`, leaving the
reference untouched.

The measurement is bounded to the window the composition actually plays.
`data-end` bounds a clip's timeline window just as `data-duration` does, and
`-ss`/`-t` belong before `-i`: after it they bound the OUTPUT, and with
`-f null` there is none, so ebur128 keeps integrating past the clip. On a
fixture whose played window is -61.8 LUFS inside a file that measures -27.9
whole, either mistake reports a loudness the composition never plays and
"corrects" an already-matched clip by tens of dB.

Two EBU R128 passes run between reading the composition and writing it, each
bounded only by a two-minute timeout, and the skill docs tell agents to keep
Studio open meanwhile — so the attribute patch is re-applied to a fresh read
and written through a temp file and a rename.

Under `--json` the failures are documents too: an agent doing
`JSON.parse(stdout)` on a bare error line throws. A pair needing more than the
+12 dB ceiling has a source-file problem rather than a mixer one — mixer gain
raises the noise floor with the signal — so the refusal names the remedy.

* fix(cli): validate --tolerance before paying for the measurement

Each EBU R128 pass is bounded at 120s and normalize-audio runs two, so
parsing the argument afterwards made a typo'd --tolerance cost both of them
before failing on something that was wrong from the start.

Not pinned by a test: the ordering is internal to the command and neither it
nor the parser is exported, so covering it would mean restructuring for a spy
rather than asserting the behaviour.

* docs(cli): restore the blank line between the preview and normalize-audio sections

Lost when I resolved the rebase conflict against the background-preview docs
by hand instead of letting the formatter near it. oxfmt --check failed on the
one file, which fails Preflight — and because preview-parity needs Preflight it
skipped, and the preview-regression gate fails closed on a skip, so a missing
newline read as a preview defect.

The quieter half: the same needs chain meant the required Test context was
never created at that head. Not failing — absent, so there was no test signal
at all on the PR.
This commit is contained in:
Miguel Ángel
2026-08-19 17:36:10 -04:00
committed by GitHub
parent 9da422fd7f
commit b3c43e2480
8 changed files with 859 additions and 2 deletions
@@ -135,6 +135,41 @@ the ambiguity instead.
## Recipes
### Compare loudness from the bytes the listener actually hears
Do not call two clips equally loud because their Studio faders, waveform peaks,
or cached asset metadata match. Those are controls and proxies, not a loudness
measurement. Resolve the exact URLs used by preview/render, download or inspect
those exact served bytes, and measure each decoded stream with FFmpeg's
`ebur128` filter. Compare the integrated LUFS values.
For a target loudness, the required move is:
```text
gain_db = target_lufs - measured_lufs
linear_gain = 10 ** (gain_db / 20)
```
When both clips are local authored `<audio>` elements with stable ids, use the
CLI instead of transcribing that arithmetic by hand:
```bash
npx hyperframes normalize-audio --reference target-audio --target user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write
```
The first command is a dry run. The second writes only the target's
`data-volume`, after accounting for both existing gains and refusing a boost
that would clip or exceed Studio's ceiling. Always choose the reference from the
author's stated intent; the command does not guess which clip should define the
mix.
Studio's clip-gain fader uses `0 dB` / linear gain `1` at its physical midpoint
and provides up to `+12 dB` on the upper half. After changing gain, measure the
served preview/render bytes again. If a listener still hears a mismatch, trust
the report and first verify the asset URL and bytes are current; do not explain
it away with matching peaks or a stale proxy measurement.
All verified with ffmpeg 8.1.1. `-hide_banner` keeps the output readable;
`volumedetect` prints to stderr, so do not silence it with `-v error`.