Skip to content

Commit 782f6bf

Browse files
fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy
- Master chain: it was carried on each parsed AudioElement, so a track the producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered audio in probeStage) never had it, and a composition whose only audio was such a track exported with no master chain, no ceiling and no warning. It is now a composition property: parseMasterFxChain(html) at compile, carried on CompiledComposition and CompositionMetadata, passed to processCompositionAudio by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain is removed so there is one source. Each forward (compile stage, local orchestrator, distributed plan) has a test that fails when it is dropped. - Plain limiter: it follows the level with no lookahead and Level after is applied past the ceiling, so it cannot promise "nothing gets past" or "never clips". Studio copy (description, panel text, knob label, summary) and the hyperframes-audio skill now say a fast or inter-sample peak can pass it and point to the true-peak limiter for a delivery limit. A copy test guards it. — Jerrai
1 parent 6dfbb76 commit 782f6bf

22 files changed

Lines changed: 349 additions & 50 deletions

‎docs/reference/audio-effects.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ dangles.
193193

194194
The master bus is the `data-fx-chain` on the top-level composition root. It runs
195195
once over the sum of every clip and every group, after each group's own chain and
196-
fader, so a limiter here sees the true peak of the whole mix. A clip can belong to
196+
fader, so a limiter here sees the peak of the whole mix. A clip can belong to
197197
only one group, so this is the way to put one effect across everything.
198198

199199
- Only the **top-level** composition root is read as the master bus. On a clip or

‎packages/core/src/audioFx.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ export const HF_AUDIO_FX: readonly HfAudioFxDef[] = [
288288
id: "limiter",
289289
label: "Limiter",
290290
group: "dynamics",
291-
description: "Hard ceiling — nothing gets past the limit.",
291+
description:
292+
"Peak limiter that follows the signal's level. It has no lookahead, so a fast peak or an inter-sample peak can pass the ceiling; use the true-peak limiter for a delivery limit.",
292293
params: [
293294
{
294295
kind: "number",

‎packages/core/src/audioFxCopy.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { defaultAudioFxParams, HF_AUDIO_FX } from "./audioFx.js";
2+
import { defaultAudioFxParams, getAudioFxDef, HF_AUDIO_FX } from "./audioFx.js";
33
import { HF_AUDIO_FX_PRESETS } from "./audioFxPresets.js";
44
import { audioBandAt, BANDS, EFFECT_COPY, PRESET_PROBLEM, SUMMARY } from "./audioFxCopy.js";
55

@@ -197,3 +197,25 @@ describe("the true-peak copy does not promise a hard ceiling", () => {
197197
expect(hint).toMatch(/headroom/);
198198
});
199199
});
200+
201+
describe("the plain limiter copy does not promise a hard ceiling", () => {
202+
// The limiter follows the signal's level with no lookahead, and Level after is applied past
203+
// the ceiling, so a fast or inter-sample peak can end above it.
204+
it("says nothing about never clipping or nothing getting past", () => {
205+
const copy = EFFECT_COPY.limiter;
206+
const summary = SUMMARY.limiter;
207+
const description = getAudioFxDef("limiter")?.description;
208+
if (!copy || !summary || !description) throw new Error("limiter copy is missing");
209+
const shown = [
210+
description,
211+
copy.does,
212+
copy.reachFor,
213+
copy.params.limit?.label,
214+
summary({}),
215+
].join("\n");
216+
expect(shown).not.toMatch(
217+
/never|nothing|guarantee|hard ceiling|no louder|must not|cannot exceed/i,
218+
);
219+
expect(shown).toMatch(/can (still )?(get )?pass|get past/i);
220+
});
221+
});

‎packages/core/src/audioFxCopy.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,13 @@ export const EFFECT_COPY: Record<string, EffectCopy> = {
152152
},
153153
limiter: {
154154
title: "Peak Ceiling",
155-
does: "Nothing gets louder than this, ever. A safety net at the end of the chain.",
156-
reachFor: "You want to be sure it never clips or spikes.",
155+
does: "Pulls the loudest moments down toward this ceiling. It reacts to what it hears, so a fast peak can still get past, and Level after is applied on top of it.",
156+
reachFor: "You want peaks held down and can accept a small overshoot.",
157157
primary: "limit",
158158
primaryEnds: { low: "A lot of headroom", high: "Right up to the edge" },
159159
params: {
160160
limit: {
161-
label: "Never exceed",
161+
label: "Ceiling",
162162
ends: { low: "A lot of headroom", high: "Right up to the edge" },
163163
},
164164
attack: { label: "How fast it catches" },
@@ -411,7 +411,7 @@ export const SUMMARY: Record<string, (p: P) => string> = {
411411
: `${n(p.gain) > 0 ? "More" : "Less"} sparkle above ${hz(p.frequency)}`,
412412
compressor: (p) =>
413413
`Evening out — ${strength(Math.min(1, (n(p.ratio, 3) - 1) / 7), ["gentle", "moderate", "firm"])}`,
414-
limiter: (p) => `Nothing louder than ${n(p.limit, -1)} dB`,
414+
limiter: (p) => `Peaks pulled down toward ${n(p.limit, -1)} dB`,
415415
truepeak: (p) => `Peaks pulled down toward ${n(p.ceiling, -1)} dBTP`,
416416
gate: (p) => `Closing gaps quieter than ${n(p.threshold, -45)} dB`,
417417
saturate: (p) =>

‎packages/engine/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
254254
export {
255255
MIXED_AUDIO_FILENAME,
256256
parseAudioElements,
257+
parseMasterFxChain,
257258
processCompositionAudio,
258259
} from "./services/audioMixer.js";
259260
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";

‎packages/engine/src/services/audioFxRender.test.ts‎

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
66
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
77
import { defaultAudioFxParams, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
88
import { applyAudioFxChain, AudioFxRenderError, readWav, writeWav } from "./audioFxRender.js";
9-
import { MIXED_AUDIO_FILENAME, parseAudioElements, processCompositionAudio } from "./audioMixer.js";
9+
import {
10+
MIXED_AUDIO_FILENAME,
11+
parseAudioElements,
12+
parseMasterFxChain,
13+
processCompositionAudio,
14+
} from "./audioMixer.js";
1015
import { resolveHeadlessShellPath } from "./browserManager.js";
1116
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
1217

@@ -556,23 +561,24 @@ describe("master bus: reading it", () => {
556561
const CHAIN = '{"version":1,"nodes":[]}';
557562
const attr = `data-fx-chain='${CHAIN}'`;
558563

559-
it("hands the root's chain to every element as the master, not as the clip's own", () => {
560-
const [a] = parseAudioElements(html(`data-composition-id="main" ${attr}`));
561-
expect(a?.masterFxChain).toBe(CHAIN);
564+
it("reads the root's chain as the master, not as a clip's own", () => {
565+
const doc = html(`data-composition-id="main" ${attr}`);
566+
expect(parseMasterFxChain(doc)).toBe(CHAIN);
567+
const [a] = parseAudioElements(doc);
562568
expect(a?.fxChain).toBeUndefined();
563569
expect(a?.groupFxChain).toBeUndefined();
564570
});
565571

566572
it("has none when the root carries none", () => {
567-
const [a] = parseAudioElements(html(`data-composition-id="main"`));
568-
expect(a?.masterFxChain).toBeUndefined();
573+
expect(parseMasterFxChain(html(`data-composition-id="main"`))).toBeUndefined();
569574
});
570575

571576
it("leaves a sub-composition root's chain as it was: not the master, not its clips' chain", () => {
572577
const inner = `<div data-composition-id="inner" ${attr}><audio id="b" src="b.wav" data-start="0" data-duration="1"></audio></div>`;
573-
const [a, b] = parseAudioElements(html(`data-composition-id="main"`, inner));
578+
const doc = html(`data-composition-id="main"`, inner);
579+
expect(parseMasterFxChain(doc)).toBeUndefined();
580+
const [a, b] = parseAudioElements(doc);
574581
for (const el of [a, b]) {
575-
expect(el?.masterFxChain).toBeUndefined();
576582
expect(el?.fxChain).toBeUndefined();
577583
expect(el?.groupFxChain).toBeUndefined();
578584
}
@@ -581,10 +587,9 @@ describe("master bus: reading it", () => {
581587
it("takes the master from the top-level root when a sub-composition root carries one too", () => {
582588
const MASTER = '{"version":1,"nodes":[{"type":"gain","id":"m","params":{"gain":-3}}]}';
583589
const inner = `<div data-composition-id="inner" ${attr}></div>`;
584-
const els = parseAudioElements(
585-
html(`data-composition-id="main" data-fx-chain='${MASTER}'`, inner),
586-
);
587-
expect(els.map((e) => e.masterFxChain)).toEqual([MASTER]);
590+
expect(
591+
parseMasterFxChain(html(`data-composition-id="main" data-fx-chain='${MASTER}'`, inner)),
592+
).toBe(MASTER);
588593
});
589594
});
590595

@@ -602,13 +607,14 @@ describe.skipIf(!HAS_FFMPEG)("master bus: reporting an unreadable chain", () =>
602607
layer: 0,
603608
volume: 1,
604609
type: "audio",
605-
masterFxChain: "{not json",
606610
},
607611
],
608612
dir,
609613
join(dir, "work"),
610614
join(dir, `bad-${MIXED_AUDIO_FILENAME}`),
611615
1,
616+
undefined,
617+
{ masterFxChain: "{not json" },
612618
);
613619
expect(result.success).toBe(false);
614620
expect(result.failures?.[0]?.elementId).toBe("master");
@@ -640,16 +646,16 @@ describe.skipIf(!HAS_BROWSER || !HAS_FFMPEG)("master bus: rendering it", () => {
640646
});
641647
const out = join(dir, `limited-${MIXED_AUDIO_FILENAME}`);
642648
const flat = join(dir, `flat-${MIXED_AUDIO_FILENAME}`);
643-
const mix = (path: string, masterFxChain?: string) => {
644-
const extra = masterFxChain ? { masterFxChain } : {};
645-
return processCompositionAudio(
646-
[element("a", extra), element("b", extra)],
649+
const mix = (path: string, masterFxChain?: string) =>
650+
processCompositionAudio(
651+
[element("a"), element("b")],
647652
dir,
648653
join(dir, "work"),
649654
path,
650655
1,
656+
undefined,
657+
{ masterFxChain },
651658
);
652-
};
653659
expect((await mix(flat)).success).toBe(true);
654660
expect((await mix(out, master)).success).toBe(true);
655661
// The unprocessed sum has nowhere to go but full scale.
@@ -661,9 +667,18 @@ describe.skipIf(!HAS_BROWSER || !HAS_FFMPEG)("master bus: rendering it", () => {
661667
const mixedPeakDb = async (
662668
name: string,
663669
elements: Parameters<typeof processCompositionAudio>[0],
670+
masterFxChain?: string,
664671
): Promise<number> => {
665672
const out = join(dir, `${name}-${MIXED_AUDIO_FILENAME}`);
666-
const result = await processCompositionAudio(elements, dir, join(dir, "work"), out, 1);
673+
const result = await processCompositionAudio(
674+
elements,
675+
dir,
676+
join(dir, "work"),
677+
out,
678+
1,
679+
undefined,
680+
{ masterFxChain },
681+
);
667682
expect(result.success).toBe(true);
668683
return maxVolumeDb(out);
669684
};
@@ -682,11 +697,10 @@ describe.skipIf(!HAS_BROWSER || !HAS_FFMPEG)("master bus: rendering it", () => {
682697
);
683698
const clip = (id: string) =>
684699
`<audio id="${id}" src="${id}.wav" data-start="0" data-end="1" data-duration="1"></audio>`;
685-
const elements = parseAudioElements(
686-
`<div data-composition-id="main" data-fx-chain='${master}'>${clip("a")}${clip("b")}</div>`,
687-
);
700+
const html = `<div data-composition-id="main" data-fx-chain='${master}'>${clip("a")}${clip("b")}</div>`;
701+
const elements = parseAudioElements(html);
688702
expect(elements).toHaveLength(2);
689-
expect(await mixedPeakDb("once", elements)).toBeCloseTo(-12, 0);
703+
expect(await mixedPeakDb("once", elements, parseMasterFxChain(html))).toBeCloseTo(-12, 0);
690704
}, 180_000);
691705

692706
it("keeps the sum in float until the chain has read it", async () => {
@@ -695,10 +709,7 @@ describe.skipIf(!HAS_BROWSER || !HAS_FFMPEG)("master bus: rendering it", () => {
695709
writeTone("a", 0.9);
696710
writeTone("b", 0.9);
697711
const masterFxChain = chainJson({ type: "gain", params: { gain: -6 } });
698-
const peak = await mixedPeakDb("float", [
699-
element("a", { masterFxChain }),
700-
element("b", { masterFxChain }),
701-
]);
712+
const peak = await mixedPeakDb("float", [element("a"), element("b")], masterFxChain);
702713
expect(peak).toBeGreaterThan(-2);
703714
}, 180_000);
704715

@@ -714,8 +725,8 @@ describe.skipIf(!HAS_BROWSER || !HAS_FFMPEG)("master bus: rendering it", () => {
714725
params: { ceiling: -20, lookahead: 3, release: 80 },
715726
}),
716727
groupVolume: 0.5,
717-
masterFxChain: chainJson({ type: "gain", params: { gain: 12 } }),
718728
};
719-
expect(await mixedPeakDb("ordered", [element("g", group)])).toBeCloseTo(-14, 0);
729+
const masterFxChain = chainJson({ type: "gain", params: { gain: 12 } });
730+
expect(await mixedPeakDb("ordered", [element("g", group)], masterFxChain)).toBeCloseTo(-14, 0);
720731
}, 180_000);
721732
});

‎packages/engine/src/services/audioMixer.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,9 @@ describe("processCompositionAudio", () => {
586586
});
587587

588588
it.each([
589-
["the master pass", { masterFxChain: gainChain }],
590-
["a group bus", { groupId: "bus", groupFxChain: gainChain }],
591-
])("removes its work directory when %s throws a fatal FX error", async (_label, extra) => {
589+
["the master pass", {}, { masterFxChain: gainChain }],
590+
["a group bus", { groupId: "bus", groupFxChain: gainChain }, {}],
591+
])("removes its work directory when %s fails fatally", async (_label, extra, config) => {
592592
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
593593
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
594594
tempDirs.push(baseDir, workDir);
@@ -614,6 +614,8 @@ describe("processCompositionAudio", () => {
614614
workDir,
615615
join(baseDir, "out.m4a"),
616616
2,
617+
undefined,
618+
config,
617619
),
618620
).rejects.toThrow(AudioFxRenderError);
619621
expect(existsSync(workDir)).toBe(false);

‎packages/engine/src/services/audioMixer.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,15 @@ export function volumeLaneKeyframes(
555555
return out;
556556
}
557557

558+
/**
559+
* The top-level root's `data-fx-chain`. It belongs to the composition, not to a clip,
560+
* so `processCompositionAudio` takes it beside the tracks: a track the render adds
561+
* itself has no parsed element to carry it.
562+
*/
563+
export function parseMasterFxChain(html: string): string | undefined {
564+
return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
565+
}
566+
558567
export function parseAudioElements(html: string): AudioElement[] {
559568
const elements: AudioElement[] = [];
560569
const { document } = parseHTML(unwrapTemplate(html));
@@ -587,7 +596,6 @@ export function parseAudioElements(html: string): AudioElement[] {
587596
resolveAudioGroups(document).map((group) => [group.id, group] as const),
588597
);
589598
const memberGroupHidden = (el: AudioMediaElement): boolean => isMemberGroupHidden(groupsById, el);
590-
const masterFxChain = readMasterFxChain(document);
591599

592600
// <audio> and <video data-has-audio> tracks differ only in the emitted id
593601

@@ -619,7 +627,6 @@ export function parseAudioElements(html: string): AudioElement[] {
619627
...(fades.fadeOut > 0 ? { fadeOut: fades.fadeOut } : {}),
620628
...(fxChain ? { fxChain } : {}),
621629
...(automation ? { automation } : {}),
622-
...(masterFxChain ? { masterFxChain } : {}),
623630
...(group
624631
? {
625632
groupId: group.id,
@@ -1170,7 +1177,10 @@ export async function processCompositionAudio(
11701177
outputPath: string,
11711178
totalDuration: number,
11721179
signal?: AbortSignal,
1173-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
1180+
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">> & {
1181+
/** Serialised master bus chain, from `parseMasterFxChain`. */
1182+
masterFxChain?: string;
1183+
},
11741184
compiledDir?: string,
11751185
): Promise<MixResult> {
11761186
const startMs = Date.now();
@@ -1517,7 +1527,7 @@ export async function processCompositionAudio(
15171527
// Parsed before any group is mixed, so a malformed master chain fails fast and
15181528
// as a failure, the way an unreadable group chain does.
15191529
let masterChain: HfAudioFxChain | undefined;
1520-
const masterFxJson = elements.find((element) => element.masterFxChain)?.masterFxChain;
1530+
const masterFxJson = config?.masterFxChain;
15211531
if (masterFxJson) {
15221532
try {
15231533
masterChain = parseAudioFxChain(masterFxJson);

‎packages/engine/src/services/audioMixer.types.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export interface AudioElement {
3535
groupFxChain?: string;
3636
groupAutomation?: string;
3737
groupVolume?: number;
38-
masterFxChain?: string;
3938
type: "audio" | "video";
4039
}
4140

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { plan } from "./plan.js";
6+
7+
const MASTER_CHAIN = '{"version":1,"nodes":[]}';
8+
const seen = vi.hoisted((): { audioInput?: { masterFxChain?: string } } => ({}));
9+
10+
vi.mock("../render/stages/audioStage.js", () => ({
11+
runAudioStage: async (input: { masterFxChain?: string; workDir: string }) => {
12+
seen.audioInput = input;
13+
return {
14+
audioOutputPath: `${input.workDir}/audio.m4a`,
15+
hasAudio: false,
16+
audioProcessMs: 0,
17+
};
18+
},
19+
}));
20+
21+
let runRoot: string;
22+
23+
beforeEach(() => {
24+
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-master-bus-"));
25+
});
26+
27+
afterEach(() => {
28+
rmSync(runRoot, { recursive: true, force: true });
29+
});
30+
31+
it("plan() gives the audio stage the composition's master chain", async () => {
32+
const projectDir = join(runRoot, "project");
33+
mkdirSync(projectDir, { recursive: true });
34+
writeFileSync(
35+
join(projectDir, "index.html"),
36+
`<!doctype html>
37+
<html><head><meta charset="utf-8"></head><body>
38+
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1" data-fx-chain='${MASTER_CHAIN}'>
39+
<p>master bus plan fixture</p>
40+
</div>
41+
</body></html>`,
42+
"utf-8",
43+
);
44+
const planDir = join(runRoot, "plan");
45+
mkdirSync(planDir, { recursive: true });
46+
47+
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
48+
49+
expect(seen.audioInput?.masterFxChain).toBe(MASTER_CHAIN);
50+
});

0 commit comments

Comments
 (0)