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
+27 -1
View File
@@ -23,7 +23,7 @@ npm install -g hyperframes
| You want to | Use |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start a project | [`init`](#init), [`add`](#add), [`catalog`](#catalog) |
| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats) |
| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`normalize-audio`](#normalize-audio) |
| Look at it, or share it | [`preview`](#preview), [`present`](#present-and-play), [`play`](#present-and-play), [`publish`](#publish) |
| Find problems | [`lint`](#lint), [`check`](#check), [`snapshot`](#snapshot), [`keyframes`](#keyframes), [`compare`](#compare-and-grade-compare), [`grade-compare`](#compare-and-grade-compare) |
| Make a file | [`render`](#render), [`benchmark`](#benchmark) |
@@ -428,6 +428,32 @@ Needs a local Chrome, the same one `render` uses. Run
within a frame or two — a different headless-Chrome audio sample rate can shift
a beat slightly.
## Look at it
### `normalize-audio`
Match one authored audio clip to a reference using the exact local media bytes
and FFmpeg's integrated EBU R128 loudness measurement.
```bash
# Measure only (the default)
npx hyperframes normalize-audio --reference target-audio --target user-audio
# Persist the computed gain on #user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write
# Agent-readable result
npx hyperframes normalize-audio --reference target-audio --target user-audio --json
```
`--reference` and `--target` are `<audio>` element ids, with or without `#`.
The reference stays unchanged; the command accounts for both clips' current
`data-volume` and writes the target's absolute matched gain. It measures
`data-media-start` and `data-duration`, rejects remote or out-of-project sources,
and refuses a result that exceeds Studio's +12 dB ceiling or would clip. Dry-run
is deliberate: inspect the measurement before passing `--write`. Use
`--tolerance <LU>` to change the default 0.5 LU no-op threshold.
### `preview`
Start a live preview server with hot reload.
+13
View File
@@ -47,6 +47,19 @@ previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to
managed lifecycle commands for machine-readable output. `--foreground --json`
prints the ready-session envelope once, then remains attached until stopped.
### `normalize-audio`
Measure two local authored audio clips with integrated LUFS and match the target
to the unchanged reference. The command is a dry run unless `--write` is passed:
```bash
npx hyperframes normalize-audio --reference target-audio --target user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write
```
It updates only the target element's `data-volume` and refuses unsafe boosts
that exceed Studio's +12 dB ceiling or would clip.
### `render`
Render a composition to MP4. Run from the project directory; the positional
+1
View File
@@ -134,6 +134,7 @@ const commandLoaders = {
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
"normalize-audio": () => import("./commands/normalize-audio.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
@@ -0,0 +1,177 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
audioNormalizationPlan,
audioTags,
loudnessMeasureArgs,
parseEbur128Summary,
resolveLocalAudioPath,
updateAudioVolume,
} from "./normalize-audio.js";
const PROJECT = "/tmp/example-project";
describe("audioTags", () => {
it("reads quoted attributes without treating a quoted > as the end of the tag", () => {
const html = `<audio id='reference' title="a > b" src="assets/ref.mp4" data-volume="1"></audio>`;
expect(audioTags(html)).toEqual([
expect.objectContaining({ id: "reference", src: "assets/ref.mp4", volume: 1 }),
]);
});
it("rejects duplicate ids instead of normalizing an arbitrary element", () => {
const html = `<audio id="voice" src="a.wav"></audio><audio id="voice" src="b.wav"></audio>`;
expect(() => audioTags(html)).toThrow(/duplicate audio id "voice"/i);
});
it("ignores audio-like text in comments, scripts, and styles", () => {
const html = `
<!-- <audio id="comment" src="comment.wav"></audio> -->
<script>const example = '<audio id="script" src="script.wav"></audio>';</script>
<style>.demo::after { content: '<audio id="style" src="style.wav">'; }</style>
<audio id="real" src="real.wav"></audio>
`;
expect(audioTags(html).map((tag) => tag.id)).toEqual(["real"]);
});
it.each([
[`<audio id="bad" src="a.wav" data-volume="loud"></audio>`, /data-volume/i],
[`<audio id="bad" src="a.wav" data-media-start="-1"></audio>`, /data-media-start/i],
[`<audio id="bad" src="a.wav" data-duration="0"></audio>`, /data-duration/i],
])("rejects an invalid authored number", (html, expected) => {
expect(() => audioTags(html)).toThrow(expected);
});
it("measures only the window a data-end trim actually plays", () => {
// `data-end` is a first-class trim the parser, the runtime and the render
// mixer all honour. Measuring the whole source for one of these clips reads
// a loudness the composition never plays, and `--write` then "corrects" an
// already-matched clip by tens of dB.
const html = `<audio id="vo" src="vo.wav" data-start="4" data-end="8"></audio>`;
expect(audioTags(html)[0]).toEqual(expect.objectContaining({ duration: 4 }));
});
it("prefers an explicit data-duration over data-end", () => {
const html = `<audio id="vo" src="vo.wav" data-start="4" data-end="8" data-duration="3"></audio>`;
expect(audioTags(html)[0]).toEqual(expect.objectContaining({ duration: 3 }));
});
it("leaves an untrimmed clip unbounded", () => {
const html = `<audio id="vo" src="vo.wav" data-start="4"></audio>`;
expect(audioTags(html)[0]).toEqual(expect.objectContaining({ duration: null }));
});
it("ignores a data-end that does not outlast its start", () => {
const html = `<audio id="vo" src="vo.wav" data-start="8" data-end="4"></audio>`;
expect(audioTags(html)[0]).toEqual(expect.objectContaining({ duration: null }));
});
});
describe("loudnessMeasureArgs", () => {
it("bounds the INPUT, so the loudness filter never sees past the clip", () => {
// `-t` after `-i` bounds the output, and `-f null` has no output worth
// bounding: ffmpeg keeps feeding the graph and ebur128 integrates audio the
// clip never plays. Measured on a fixture whose played window is -61.8 LUFS,
// output-side `-t` reported -33.8.
const args = loudnessMeasureArgs("clip.wav", { mediaStart: 2, duration: 4 });
expect(args.indexOf("-ss")).toBeLessThan(args.indexOf("-i"));
expect(args.indexOf("-t")).toBeLessThan(args.indexOf("-i"));
expect(args.slice(args.indexOf("-ss"), args.indexOf("-i"))).toEqual(["-ss", "2", "-t", "4"]);
});
it("leaves an untrimmed clip unbounded", () => {
const args = loudnessMeasureArgs("clip.wav", { mediaStart: 0, duration: null });
expect(args).not.toContain("-ss");
expect(args).not.toContain("-t");
});
});
describe("resolveLocalAudioPath", () => {
it("resolves a local source and strips query and fragment suffixes", () => {
expect(resolveLocalAudioPath(PROJECT, "assets/voice%20one.wav?v=2#clip")).toBe(
resolve(PROJECT, "assets/voice one.wav"),
);
});
it.each(["https://cdn.example.com/a.wav", "/etc/passwd", "../outside.wav", "data:x"])(
"rejects a non-project source: %s",
(src) => expect(() => resolveLocalAudioPath(PROJECT, src)).toThrow(/local project file/i),
);
});
describe("parseEbur128Summary", () => {
it("takes the final integrated loudness and true-peak summary", () => {
const stderr = `
[Parsed_ebur128_0] t: 1.0 I: -18.2 LUFS
[Parsed_ebur128_0] Summary:
Integrated loudness:
I: -15.5 LUFS
Threshold: -25.5 LUFS
True peak:
Peak: -3.2 dBFS
`;
expect(parseEbur128Summary(stderr)).toEqual({ integratedLufs: -15.5, truePeakDbfs: -3.2 });
});
it("fails when FFmpeg did not produce a usable summary", () => {
expect(() => parseEbur128Summary("no audio stream")).toThrow(/integrated loudness/i);
});
});
describe("audioNormalizationPlan", () => {
it("preserves the reference and attenuates the louder target", () => {
const plan = audioNormalizationPlan(
{ id: "target-audio", volume: 1, integratedLufs: -15.5, truePeakDbfs: -3.2 },
{ id: "user-audio", volume: 1, integratedLufs: -11.7, truePeakDbfs: -0.2 },
);
expect(plan.gainDb).toBeCloseTo(-3.8, 6);
expect(plan.volume).toBeCloseTo(0.645654, 5);
expect(plan.projectedLufs).toBeCloseTo(-15.5, 6);
expect(plan.projectedTruePeakDbfs).toBeCloseTo(-4, 6);
});
it("includes the reference's authored gain in the target", () => {
const plan = audioNormalizationPlan(
{ id: "reference", volume: 2, integratedLufs: -20, truePeakDbfs: -8 },
{ id: "target", volume: 0.5, integratedLufs: -18, truePeakDbfs: -6 },
);
expect(plan.referenceLufs).toBeCloseTo(-13.9794, 4);
expect(plan.volume).toBeCloseTo(1.588656, 6);
expect(plan.projectedLufs).toBeCloseTo(plan.referenceLufs, 6);
});
it("refuses a gain beyond Studio's +12 dB ceiling", () => {
expect(() =>
audioNormalizationPlan(
{ id: "reference", volume: 1, integratedLufs: -5, truePeakDbfs: -1 },
{ id: "target", volume: 1, integratedLufs: -30, truePeakDbfs: -30 },
),
).toThrow(/\+12 dB/i);
});
it("refuses a boost that would clip", () => {
expect(() =>
audioNormalizationPlan(
{ id: "reference", volume: 1, integratedLufs: -10, truePeakDbfs: -1 },
{ id: "target", volume: 1, integratedLufs: -15, truePeakDbfs: -2 },
),
).toThrow(/clip/i);
});
});
describe("updateAudioVolume", () => {
it("updates only the selected audio element and preserves surrounding source", () => {
const html = `<!doctype html>\n<audio id="ref" src="a.wav" data-volume="1"></audio>\n<audio data-volume='1' id='target' src='b.wav'></audio>\n`;
expect(updateAudioVolume(html, "target", 0.645654)).toBe(
`<!doctype html>\n<audio id="ref" src="a.wav" data-volume="1"></audio>\n<audio data-volume='0.645654' id='target' src='b.wav'></audio>\n`,
);
});
it("adds data-volume when it is absent", () => {
expect(updateAudioVolume(`<audio id="target" src="b.wav" />`, "target", 2)).toBe(
`<audio id="target" src="b.wav" data-volume="2" />`,
);
});
});
@@ -0,0 +1,601 @@
import { execFile } from "node:child_process";
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { formatAudioGain, MAX_AUDIO_GAIN_DB } from "@hyperframes/core/audio-gain";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { defineCommand } from "citty";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { failCommand } from "../utils/commandResult.js";
import type { Example } from "./_examples.js";
const execFileAsync = promisify(execFile);
const FFMPEG_TIMEOUT_MS = 120_000;
const DEFAULT_TOLERANCE_LU = 0.5;
interface AttributeSpan {
value: string;
valueStart: number;
valueEnd: number;
}
interface TagRange {
start: number;
end: number;
}
interface ParsedStartTag extends TagRange {
closing: boolean;
name: string;
}
interface ScanResult {
cursor: number;
audioRange: TagRange | null;
}
export interface AudioTag {
id: string;
src: string;
volume: number;
mediaStart: number;
duration: number | null;
start: number;
end: number;
volumeAttribute: AttributeSpan | null;
}
export interface LoudnessMeasurement {
integratedLufs: number;
truePeakDbfs: number;
}
export interface NormalizationTrack extends LoudnessMeasurement {
id: string;
volume: number;
}
export interface AudioNormalizationPlan {
referenceId: string;
targetId: string;
referenceLufs: number;
targetLufs: number;
previousVolume: number;
volume: number;
gainDb: number;
changeDb: number;
projectedLufs: number;
projectedTruePeakDbfs: number;
}
function authoredNumber(
raw: string | undefined,
fallback: number,
attribute: string,
id: string,
strictlyPositive = false,
): number {
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isFinite(value) || (strictlyPositive ? value <= 0 : value < 0)) {
throw new Error(`Audio #${id} has an invalid ${attribute}: ${raw}`);
}
return value;
}
function attributeFromMatch(
match: RegExpMatchArray,
absoluteOffset: number,
): [string, AttributeSpan] | null {
if (match.index === undefined) return null;
const name = match[1]?.toLowerCase();
const value = matchedAttributeValue(match);
if (!name || value === undefined) return null;
const whole = match[0];
const localValueStart = attributeValueStart(whole);
const valueStart = absoluteOffset + match.index + localValueStart;
return [name, { value, valueStart, valueEnd: valueStart + value.length }];
}
function matchedAttributeValue(match: RegExpMatchArray): string | undefined {
return match[2] ?? match[3] ?? match[4];
}
function attributeValueStart(attribute: string): number {
let cursor = attribute.indexOf("=") + 1;
while (/\s/.test(attribute[cursor] ?? "")) cursor += 1;
const quote = attribute[cursor];
return quote === '"' || quote === "'" ? cursor + 1 : cursor;
}
function attributesInTag(html: string, from: number, to: number): Map<string, AttributeSpan> {
const tag = html.slice(from, to);
const nameEnd = tag.search(/\s|\/?\s*>/);
const attributes = new Map<string, AttributeSpan>();
if (nameEnd < 0) return attributes;
const pattern = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
for (const match of tag.slice(nameEnd).matchAll(pattern)) {
const attribute = attributeFromMatch(match, from + nameEnd);
if (attribute) attributes.set(...attribute);
}
return attributes;
}
function startTagEnd(html: string, start: number): number {
let quote = "";
let end = start + 1;
for (; end < html.length; end += 1) {
const char = html[end];
if (quote) {
if (char === quote) quote = "";
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === ">") {
return end + 1;
}
}
throw new Error("Unterminated HTML start tag");
}
function commentEnd(html: string, start: number): number {
const end = html.indexOf("-->", start + 4);
if (end < 0) throw new Error("Unterminated HTML comment");
return end + 3;
}
function parsedStartTag(html: string, start: number): ParsedStartTag | null {
const match = html.slice(start).match(/^<\s*(\/?)\s*([a-z][a-z\d:-]*)\b/i);
if (!match) return null;
return {
start,
end: startTagEnd(html, start),
closing: match[1] === "/",
name: match[2]?.toLowerCase() ?? "",
};
}
function rawTextElementEnd(html: string, lower: string, tag: ParsedStartTag): number | null {
if (tag.closing || (tag.name !== "script" && tag.name !== "style")) return null;
const closeStart = lower.indexOf(`</${tag.name}`, tag.end);
if (closeStart < 0) throw new Error(`Unterminated <${tag.name}> element`);
return startTagEnd(html, closeStart);
}
function scanMarkup(html: string, lower: string, start: number): ScanResult {
if (html.startsWith("<!--", start)) {
return { cursor: commentEnd(html, start), audioRange: null };
}
const tag = parsedStartTag(html, start);
if (!tag) {
const marker = html[start + 1];
const cursor = marker === "!" || marker === "?" ? startTagEnd(html, start) : start + 1;
return { cursor, audioRange: null };
}
const rawTextEnd = rawTextElementEnd(html, lower, tag);
if (rawTextEnd !== null) return { cursor: rawTextEnd, audioRange: null };
const audioRange = !tag.closing && tag.name === "audio" ? { start, end: tag.end } : null;
return { cursor: tag.end, audioRange };
}
function audioTagRanges(html: string): TagRange[] {
const lower = html.toLowerCase();
const ranges: TagRange[] = [];
let cursor = 0;
while (cursor < html.length) {
const start = html.indexOf("<", cursor);
if (start < 0) break;
const scanned = scanMarkup(html, lower, start);
if (scanned.audioRange) ranges.push(scanned.audioRange);
cursor = scanned.cursor;
}
return ranges;
}
function trimmedAttribute(attributes: Map<string, AttributeSpan>, name: string): string {
return attributes.get(name)?.value.trim() ?? "";
}
/**
* How much of the source the composition actually plays.
*
* `data-duration` is the explicit trim; absent, `data-end` still bounds the
* clip's timeline window, and the parser, the runtime and the render mixer all
* honour it. Measuring the whole file for a `data-end`-trimmed clip reads a
* loudness the composition never plays — and `--write` then "corrects" an
* already-matched clip by tens of dB.
*/
function authoredDuration(attributes: Map<string, AttributeSpan>, id: string): number | null {
const raw = attributes.get("data-duration")?.value;
if (raw !== undefined) return authoredNumber(raw, 0, "data-duration", id, true);
const rawEnd = attributes.get("data-end")?.value;
if (rawEnd === undefined) return null;
const end = authoredNumber(rawEnd, 0, "data-end", id, true);
const start = authoredNumber(attributes.get("data-start")?.value, 0, "data-start", id);
return end > start ? end - start : null;
}
function requiredAudioIdentity(attributes: Map<string, AttributeSpan>) {
const id = trimmedAttribute(attributes, "id");
const src = trimmedAttribute(attributes, "src");
if (!id) throw new Error("Every normalized <audio> element needs an id");
if (!src) throw new Error(`Audio #${id} has no src`);
return { id, src };
}
function audioTagFromRange(html: string, range: TagRange): AudioTag {
const attributes = attributesInTag(html, range.start, range.end);
const { id, src } = requiredAudioIdentity(attributes);
const volumeAttribute = attributes.get("data-volume") ?? null;
return {
id,
src,
volume: authoredNumber(volumeAttribute?.value, 1, "data-volume", id),
mediaStart: authoredNumber(
attributes.get("data-media-start")?.value,
0,
"data-media-start",
id,
),
duration: authoredDuration(attributes, id),
start: range.start,
end: range.end,
volumeAttribute,
};
}
/** Read the authored audio clips without reserializing the composition. */
export function audioTags(html: string): AudioTag[] {
const seen = new Set<string>();
const tags: AudioTag[] = [];
for (const range of audioTagRanges(html)) {
const tag = audioTagFromRange(html, range);
if (seen.has(tag.id)) throw new Error(`Duplicate audio id "${tag.id}"`);
seen.add(tag.id);
tags.push(tag);
}
return tags;
}
/** Resolve only a file contained by the project. Remote and traversal sources are not measured. */
export function resolveLocalAudioPath(projectDir: string, src: string): string {
const withoutSuffix = src.split(/[?#]/, 1)[0] ?? "";
let decoded = "";
try {
decoded = decodeURIComponent(withoutSuffix);
} catch {
throw new Error(`Audio source must be a local project file: ${src}`);
}
if (!decoded || isAbsolute(decoded) || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(decoded)) {
throw new Error(`Audio source must be a local project file: ${src}`);
}
const root = resolve(projectDir);
const file = resolve(root, decoded);
const rel = relative(root, file);
if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
throw new Error(`Audio source must be a local project file: ${src}`);
}
return file;
}
/** Parse FFmpeg's final EBU R128 summary, not its per-window log lines. */
export function parseEbur128Summary(stderr: string): LoudnessMeasurement {
const summaryAt = stderr.lastIndexOf("Summary:");
const summary = summaryAt >= 0 ? stderr.slice(summaryAt) : "";
const integrated = summary.match(/\bI:\s*(-?\d+(?:\.\d+)?)\s+LUFS\b/);
const peak = summary.match(/\bPeak:\s*(-?\d+(?:\.\d+)?)\s+dBFS\b/);
const integratedLufs = Number(integrated?.[1]);
const truePeakDbfs = Number(peak?.[1]);
if (!Number.isFinite(integratedLufs)) {
throw new Error("FFmpeg did not report integrated loudness for this audio stream");
}
if (!Number.isFinite(truePeakDbfs)) {
throw new Error("FFmpeg did not report true peak for this audio stream");
}
return { integratedLufs, truePeakDbfs };
}
function gainDb(volume: number): number {
return volume > 0 ? 20 * Math.log10(volume) : Number.NEGATIVE_INFINITY;
}
/** Calculate the target's absolute authored gain while keeping the reference unchanged. */
export function audioNormalizationPlan(
referenceTrack: NormalizationTrack,
targetTrack: NormalizationTrack,
): AudioNormalizationPlan {
if (!(referenceTrack.volume > 0) || !(targetTrack.volume > 0)) {
throw new Error("Muted audio cannot be used for loudness matching");
}
const referenceLufs = referenceTrack.integratedLufs + gainDb(referenceTrack.volume);
const targetLufs = targetTrack.integratedLufs + gainDb(targetTrack.volume);
const wantedGainDb = referenceLufs - targetTrack.integratedLufs;
if (wantedGainDb > MAX_AUDIO_GAIN_DB + 1e-9) {
throw new Error(
`Matching #${targetTrack.id} needs ${wantedGainDb.toFixed(1)} dB, beyond the +${MAX_AUDIO_GAIN_DB} dB authoring ceiling. ` +
`A gap this large belongs in the source file, not the mixer — mixer gain raises the noise floor with the signal. ` +
`Normalize ${targetTrack.id}'s asset offline (e.g. ffmpeg loudnorm), or lower #${referenceTrack.id} instead.`,
);
}
const volume = 10 ** (wantedGainDb / 20);
const projectedTruePeakDbfs = targetTrack.truePeakDbfs + wantedGainDb;
if (projectedTruePeakDbfs > 0) {
throw new Error(
`Matching #${targetTrack.id} would clip at +${projectedTruePeakDbfs.toFixed(1)} dBFS; limit or preprocess the source first`,
);
}
return {
referenceId: referenceTrack.id,
targetId: targetTrack.id,
referenceLufs,
targetLufs,
previousVolume: targetTrack.volume,
volume,
gainDb: wantedGainDb,
changeDb: wantedGainDb - gainDb(targetTrack.volume),
projectedLufs: targetTrack.integratedLufs + wantedGainDb,
projectedTruePeakDbfs,
};
}
/** Patch one authored attribute in place so scripts/styles/comments remain byte-stable. */
export function updateAudioVolume(html: string, id: string, volume: number): string {
const tag = audioTags(html).find((candidate) => candidate.id === id);
if (!tag) throw new Error(`Audio #${id} was not found`);
const value = formatAudioGain(volume);
if (tag.volumeAttribute) {
return (
html.slice(0, tag.volumeAttribute.valueStart) +
value +
html.slice(tag.volumeAttribute.valueEnd)
);
}
let insertion = tag.end - 1;
let tail = insertion - 1;
while (tail >= tag.start && /\s/.test(html[tail] ?? "")) tail -= 1;
if (html[tail] === "/") {
insertion = tail;
while (insertion > tag.start && /\s/.test(html[insertion - 1] ?? "")) insertion -= 1;
} else {
while (insertion > tag.start && /\s/.test(html[insertion - 1] ?? "")) insertion -= 1;
}
return html.slice(0, insertion) + ` data-volume="${value}"` + html.slice(insertion);
}
/**
* FFmpeg arguments that measure exactly the window the composition plays.
*
* `-ss` / `-t` go BEFORE `-i`, as INPUT options. After `-i` they bound the
* output, and with `-f null` there is no real output to bound: ffmpeg keeps
* feeding the filter graph past the limit, so ebur128 integrates audio the clip
* never plays. Measured on a 12 s file whose first 4 s — the played window — is
* -61.8 LUFS: output-side `-t 4` reported -33.8 LUFS, input-side reports -61.8,
* the same as physically cutting the file first.
*/
export function loudnessMeasureArgs(
file: string,
tag: Pick<AudioTag, "mediaStart" | "duration">,
): string[] {
const args = ["-hide_banner", "-nostats"];
if (tag.mediaStart > 0) args.push("-ss", String(tag.mediaStart));
if (tag.duration !== null && tag.duration > 0) args.push("-t", String(tag.duration));
args.push("-i", file, "-map", "0:a:0", "-vn", "-af", "ebur128=peak=true", "-f", "null", "-");
return args;
}
async function measureAudio(
ffmpegPath: string,
file: string,
tag: Pick<AudioTag, "mediaStart" | "duration">,
): Promise<LoudnessMeasurement> {
const result = await execFileAsync(ffmpegPath, loudnessMeasureArgs(file, tag), {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: FFMPEG_TIMEOUT_MS,
});
return parseEbur128Summary(result.stderr);
}
/**
* Under `--json` every outcome has to be one parseable document, including the
* failures — this command exists to be driven by an agent, and an agent doing
* `JSON.parse(stdout)` on a bare error line just throws.
*/
function fail(message: string, json: boolean): never {
if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
else console.error(c.error(message));
failCommand();
}
function byId(tags: readonly AudioTag[], rawId: string, role: string): AudioTag {
const id = rawId.trim().replace(/^#/, "");
const tag = tags.find((candidate) => candidate.id === id);
if (!tag) throw new Error(`${role} audio #${id} was not found`);
return tag;
}
interface NormalizeAudioOptions {
dir?: string;
reference: string;
target: string;
tolerance: string;
write: boolean;
}
interface NormalizeAudioResult extends AudioNormalizationPlan {
ok: true;
wrote: boolean;
withinTolerance: boolean;
tolerance: number;
indexPath: string;
}
function selectedTags(html: string, referenceId: string, targetId: string) {
const tags = audioTags(html);
const referenceTag = byId(tags, referenceId, "Reference");
const targetTag = byId(tags, targetId, "Target");
if (referenceTag.id === targetTag.id) {
throw new Error("Reference and target must be different audio elements");
}
return { referenceTag, targetTag };
}
function requiredFfmpeg(): string {
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
if (!ffmpegPath) throw new Error("FFmpeg is required to measure integrated loudness");
return ffmpegPath;
}
function existingAudioFile(projectDir: string, tag: AudioTag): string {
const file = resolveLocalAudioPath(projectDir, tag.src);
if (!existsSync(file)) {
throw new Error(`Audio #${tag.id} file was not found: ${relative(projectDir, file)}`);
}
return file;
}
async function measuredPlan(
projectDir: string,
referenceTag: AudioTag,
targetTag: AudioTag,
): Promise<AudioNormalizationPlan> {
const ffmpegPath = requiredFfmpeg();
const referenceFile = existingAudioFile(projectDir, referenceTag);
const targetFile = existingAudioFile(projectDir, targetTag);
const [referenceMeasurement, targetMeasurement] = await Promise.all([
measureAudio(ffmpegPath, referenceFile, referenceTag),
measureAudio(ffmpegPath, targetFile, targetTag),
]);
return audioNormalizationPlan(
{ id: referenceTag.id, volume: referenceTag.volume, ...referenceMeasurement },
{ id: targetTag.id, volume: targetTag.volume, ...targetMeasurement },
);
}
/** Never leave a half-written composition behind if the process dies mid-write. */
function writeAtomically(path: string, contents: string): void {
const temporary = `${path}.hf-normalize-${process.pid}.tmp`;
writeFileSync(temporary, contents);
renameSync(temporary, path);
}
function parsedTolerance(raw: string): number {
const tolerance = Number(raw);
if (!Number.isFinite(tolerance) || tolerance < 0) {
throw new Error("--tolerance must be a non-negative number");
}
return tolerance;
}
async function normalizeAudio(options: NormalizeAudioOptions): Promise<NormalizeAudioResult> {
const project = resolveProject(options.dir);
const html = readFileSync(project.indexPath, "utf8");
const { referenceTag, targetTag } = selectedTags(html, options.reference, options.target);
// Before the measurement, not after: each EBU R128 pass is bounded at 120s, so
// validating this afterwards made a typo'd --tolerance cost two of them before
// failing on an argument that was wrong the whole time.
const tolerance = parsedTolerance(options.tolerance);
const plan = await measuredPlan(project.dir, referenceTag, targetTag);
const withinTolerance = Math.abs(plan.referenceLufs - plan.targetLufs) <= tolerance;
const wrote = options.write && !withinTolerance;
// Re-read: two EBU R128 passes ran since the snapshot above, each bounded
// only by FFMPEG_TIMEOUT_MS, and the skill tells agents to keep Studio open
// on the project while normalizing. Serializing the stale snapshot would
// silently revert every edit made during that window. The write is one
// attribute patch, so re-applying it to the current file is the same edit.
if (wrote) {
writeAtomically(
project.indexPath,
updateAudioVolume(readFileSync(project.indexPath, "utf8"), targetTag.id, plan.volume),
);
}
return { ok: true, wrote, withinTolerance, tolerance, indexPath: project.indexPath, ...plan };
}
function printHumanResult(result: NormalizeAudioResult): void {
console.log(
`${c.bold(`#${result.referenceId}`)} ${result.referenceLufs.toFixed(1)} LUFS → ` +
`${c.bold(`#${result.targetId}`)} ${result.targetLufs.toFixed(1)} LUFS`,
);
if (result.withinTolerance) {
console.log(
c.success(`Already matched within ${result.tolerance.toFixed(1)} LU; no change needed.`),
);
return;
}
console.log(
`Set #${result.targetId} data-volume ${result.previousVolume.toFixed(3)}${result.volume.toFixed(6)} ` +
`(${result.changeDb >= 0 ? "+" : ""}${result.changeDb.toFixed(1)} dB).`,
);
console.log(
result.wrote
? c.success(`Updated ${relative(process.cwd(), result.indexPath) || "index.html"}.`)
: c.dim("Dry run only. Pass --write to persist this gain."),
);
}
export const examples: Example[] = [
[
"Measure two authored clips and preview the matching gain",
"hyperframes normalize-audio --reference target-audio --target user-audio",
],
[
"Persist the measured gain into index.html",
"hyperframes normalize-audio --reference target-audio --target user-audio --write",
],
];
export default defineCommand({
meta: {
name: "normalize-audio",
description: "Match one local audio clip to another using integrated LUFS",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
reference: {
type: "string",
description: "Audio element id whose effective loudness should be preserved",
required: true,
},
target: {
type: "string",
description: "Audio element id whose data-volume should be matched",
required: true,
},
write: {
type: "boolean",
description: "Persist the measured target gain into index.html",
default: false,
},
tolerance: {
type: "string",
description: `Skip writes within this many LU (default ${DEFAULT_TOLERANCE_LU})`,
default: String(DEFAULT_TOLERANCE_LU),
},
json: { type: "boolean", description: "Print machine-readable output", default: false },
},
async run({ args }) {
try {
const result = await normalizeAudio({
dir: args.dir,
reference: args.reference,
target: args.target,
tolerance: args.tolerance,
write: args.write,
});
if (args.json) {
const { indexPath: _, ...jsonResult } = result;
console.log(JSON.stringify(jsonResult, null, 2));
return;
}
printHumanResult(result);
} catch (error) {
fail(error instanceof Error ? error.message : String(error), Boolean(args.json));
}
},
});
+4
View File
@@ -46,6 +46,10 @@ const GROUPS: Group[] = [
"media-treatment",
"Discover, apply, or clear deterministic media treatments on one media element",
],
[
"normalize-audio",
"Match one authored audio clip's loudness to another using integrated LUFS",
],
[
"grade-compare",
"Render candidate color grades onto a reference frame as one labeled comparison PNG",
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 121
},
"hyperframes-audio": {
"hash": "c96ccac4b1127b8c",
"hash": "6bdb36e1571586fe",
"files": 6
},
"hyperframes-cli": {
@@ -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`.