Skip to content

Commit 1fd228e

Browse files
vanceingallsclaude
andcommitted
feat(engine): bake automation envelopes into the render
The offline render schedules FX lanes with the same scheduler preview uses, inside the OfflineAudioContext that already runs the same graph builders. The input WAV is the clip's own audio from its first sample, so clip-local time is offline time and the envelope needs no offset. Volume lanes take the existing PCM bake rather than a second mechanism: the lane is converted to keyframes, so a straight fade stays two of them and only a bent segment is sampled — the baker interpolates linearly and would otherwise quietly straighten the curve. A volume lane supersedes keyframes probed from the timeline, which `lint` already warns about. A browser test sweeps a lowpass from below a 2 kHz tone to well above it and measures both ends. Parsing the envelope is not the same as scheduling it, and only running the real thing tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b155400 commit 1fd228e

6 files changed

Lines changed: 277 additions & 12 deletions

File tree

packages/core/stubs/audio-fx-runtime-entry.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
* ends share a single implementation rather than two that have to be kept in
88
* agreement.
99
*
10-
* Exposes `window.__HF_AUDIO_FX.render(pcm, sampleRate, chain)`, which returns
11-
* the processed samples.
10+
* Exposes `window.__HF_AUDIO_FX.render(pcm, sampleRate, chain, automation)`,
11+
* which returns the processed samples.
1212
*/
1313

1414
import {
1515
buildFxChain,
1616
chainNeedsWorklets,
1717
ensureAudioFxWorklets,
1818
} from "../src/audio/audioFxGraph.js";
19+
import { scheduleChainAutomation } from "../src/audio/audioFxAutomation.js";
20+
import { parseAutomation, resolveAutomation } from "../src/audioAutomation.js";
1921
import { parseAudioFxChain, type HfAudioFxChain } from "../src/audioFx.js";
2022

2123
declare global {
@@ -25,6 +27,7 @@ declare global {
2527
planes: Float32Array[],
2628
sampleRate: number,
2729
chainJson: string,
30+
automationJson?: string,
2831
): Promise<Float32Array[]>;
2932
};
3033
}
@@ -42,6 +45,7 @@ declare global {
4245
* mix, so how far a tail may run past a clip's end is a product decision rather
4346
* than something to pick here.
4447
*/
48+
4549
/** The clip's audio as an AudioBuffer, a plane per channel. */
4650
function toBuffer(
4751
ctx: OfflineAudioContext,
@@ -71,6 +75,7 @@ async function render(
7175
planes: Float32Array[],
7276
sampleRate: number,
7377
chainJson: string,
78+
automationJson?: string,
7479
): Promise<Float32Array[]> {
7580
const chain: HfAudioFxChain = parseAudioFxChain(chainJson);
7681
const channels = Math.max(1, planes.length);
@@ -83,6 +88,19 @@ async function render(
8388
source.buffer = toBuffer(ctx, planes, channels, frames, sampleRate);
8489

8590
const fx = buildFxChain(ctx, chain);
91+
92+
// The input WAV is the clip's own audio from its first sample, so clip-local
93+
// time is offline time — the envelope needs no offset here. Same scheduler as
94+
// preview, which is what makes the two agree.
95+
if (automationJson) {
96+
const automation = resolveAutomation(parseAutomation(automationJson), chain);
97+
scheduleChainAutomation(automation, chain, fx.nodes, {
98+
scheduledAt: 0,
99+
elapsed: 0,
100+
rate: 1,
101+
});
102+
}
103+
86104
source.connect(fx.input);
87105
fx.output.connect(ctx.destination);
88106
source.start();
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseAudioElements, volumeLaneKeyframes } from "./audioMixer.js";
3+
import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
4+
5+
const lanes = (points: HfAutomationLane["points"], target = "volume") => ({
6+
lanes: [{ target, points }],
7+
});
8+
9+
describe("volumeLaneKeyframes", () => {
10+
it("keeps a straight fade at two keyframes, in composition time", () => {
11+
const out = volumeLaneKeyframes(
12+
lanes([
13+
{ t: 0, v: 1 },
14+
{ t: 2, v: 0 },
15+
]),
16+
5,
17+
2,
18+
);
19+
expect(out).toEqual([
20+
{ time: 5, volume: 1 },
21+
{ time: 7, volume: 0 },
22+
]);
23+
});
24+
25+
it("holds the first value before the envelope starts", () => {
26+
const out = volumeLaneKeyframes(
27+
lanes([
28+
{ t: 1, v: 0.3 },
29+
{ t: 2, v: 1 },
30+
]),
31+
0,
32+
2,
33+
);
34+
// Not `data-volume` at t=0 — the lane's own first value.
35+
expect(out?.[0]).toEqual({ time: 0, volume: 0.3 });
36+
});
37+
38+
it("holds the last value out to the clip end", () => {
39+
const out = volumeLaneKeyframes(
40+
lanes([
41+
{ t: 0, v: 1 },
42+
{ t: 1, v: 0.5 },
43+
]),
44+
0,
45+
4,
46+
);
47+
expect(out?.at(-1)).toEqual({ time: 4, volume: 0.5 });
48+
});
49+
50+
it("samples a bent segment, which the linear baker would straighten", () => {
51+
const straight = volumeLaneKeyframes(
52+
lanes([
53+
{ t: 0, v: 0 },
54+
{ t: 2, v: 1 },
55+
]),
56+
0,
57+
2,
58+
);
59+
const bent = volumeLaneKeyframes(
60+
lanes([
61+
{ t: 0, v: 0, curve: 1 },
62+
{ t: 2, v: 1 },
63+
]),
64+
0,
65+
2,
66+
);
67+
expect(straight?.length).toBe(2);
68+
expect(bent?.length ?? 0).toBeGreaterThan(30);
69+
// Same endpoints, different path between them.
70+
expect(bent?.[0]).toEqual({ time: 0, volume: 0 });
71+
expect(bent?.at(-1)).toEqual({ time: 2, volume: 1 });
72+
const mid = bent?.find((k) => Math.abs(k.time - 1) < 0.02);
73+
expect(mid?.volume ?? 1).toBeLessThan(0.4);
74+
});
75+
76+
it("has nothing to bake for a track whose only lane is an FX one", () => {
77+
expect(volumeLaneKeyframes(lanes([{ t: 0, v: 200 }], "fx.n1.frequency"), 0, 2)).toBeNull();
78+
});
79+
});
80+
81+
describe("parseAudioElements", () => {
82+
it("carries the automation attribute through to the mixer", () => {
83+
const automation = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
84+
const html = `<!DOCTYPE html><html><body>
85+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
86+
<audio id="bgm" src="a.wav" data-start="0" data-duration="10" data-automation='${automation}'></audio>
87+
</div></body></html>`;
88+
const [el] = parseAudioElements(html);
89+
expect(el?.automation).toBe(automation);
90+
});
91+
92+
it("leaves automation unset when the element has none", () => {
93+
const html = `<!DOCTYPE html><html><body>
94+
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="10">
95+
<audio id="bgm" src="a.wav" data-start="0" data-duration="10"></audio>
96+
</div></body></html>`;
97+
expect(parseAudioElements(html)[0]?.automation).toBeUndefined();
98+
});
99+
});

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ function tone(path: string, seconds = 0.3, freq = 440): void {
5656
writeWav(path, s, SR);
5757
}
5858

59+
/** RMS of one slice, for comparing how loud a moment is against another. */
60+
const sliceRms = (s: Float32Array, from: number, to: number): number => {
61+
const a = Math.max(0, Math.floor(from * SR));
62+
const b = Math.min(s.length, Math.floor(to * SR));
63+
let sum = 0;
64+
for (let i = a; i < b; i++) sum += (s[i] ?? 0) * (s[i] ?? 0);
65+
return Math.sqrt(sum / Math.max(1, b - a));
66+
};
67+
5968
const rms = (s: Float32Array): number =>
6069
Math.sqrt(s.reduce((a, x) => a + x * x, 0) / Math.max(1, s.length));
6170
const db = (x: number): number => 20 * Math.log10(x + 1e-30);
@@ -221,6 +230,45 @@ describe.skipIf(!HAS_BROWSER)("browser render", () => {
221230
expect(db(rms(readWav(outPath).samples))).toBeLessThan(db(rms(readWav(input).samples)) - 3);
222231
}, 180_000);
223232

233+
it("sweeps a filter across the clip when a lane automates it", async () => {
234+
// A 2 kHz tone under a lowpass whose cutoff rises from below it to well
235+
// above: the start should be attenuated and the end should not. This is
236+
// the whole point of the render path — the envelope has to be *scheduled*
237+
// offline, not merely parsed.
238+
const input = join(dir, "sweep-in.wav");
239+
tone(input, 1.5, 2000);
240+
const outPath = join(dir, "sweep-out.wav");
241+
await applyAudioFxChain(
242+
input,
243+
{
244+
version: 1,
245+
nodes: [{ type: "lowpass", id: "n1", enabled: true, params: { frequency: 300, q: 0.707 } }],
246+
},
247+
outPath,
248+
{
249+
trackId: "t",
250+
automation: {
251+
version: 1,
252+
lanes: [
253+
{
254+
target: "fx.n1.frequency",
255+
points: [
256+
{ t: 0, v: 300 },
257+
{ t: 1.5, v: 16000 },
258+
],
259+
},
260+
],
261+
},
262+
},
263+
);
264+
const after = readWav(outPath).samples;
265+
const head = db(sliceRms(after, 0.05, 0.25));
266+
const tail = db(sliceRms(after, 1.2, 1.45));
267+
// Opening the filter past the tone has to leave it far louder than when
268+
// the cutoff sat an octave and a half below it.
269+
expect(tail).toBeGreaterThan(head + 15);
270+
}, 180_000);
271+
224272
it("renders a multi-effect chain including reverb", async () => {
225273
const input = join(dir, "in.wav");
226274
tone(input);

packages/engine/src/services/audioFxRender.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { join } from "node:path";
1818
import { pathToFileURL } from "node:url";
1919
import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime";
2020
import { enabledAudioFxNodes, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
21+
import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation";
2122
import { acquireBrowser } from "./browserManager.js";
2223

2324
export class AudioFxRenderError extends Error {
@@ -184,7 +185,7 @@ export async function applyAudioFxChain(
184185
inputWav: string,
185186
chain: HfAudioFxChain,
186187
outputWav: string,
187-
options: { trackId: string; signal?: AbortSignal },
188+
options: { trackId: string; signal?: AbortSignal; automation?: HfAutomation },
188189
): Promise<string> {
189190
if (enabledAudioFxNodes(chain).length === 0) return inputWav;
190191
if (!existsSync(inputWav)) {
@@ -216,7 +217,12 @@ export async function applyAudioFxChain(
216217
await page.addScriptTag({ content: getAudioFxRuntimeScript() });
217218

218219
const rendered = (await page.evaluate(
219-
async ([channelB64, rate, chainJson]: [string[], number, string]) => {
220+
async ([channelB64, rate, chainJson, automationJson]: [
221+
string[],
222+
number,
223+
string,
224+
string,
225+
]) => {
220226
const decode = (b64: string): Float32Array => {
221227
const bin = atob(b64);
222228
const bytes = new Uint8Array(bin.length);
@@ -226,12 +232,22 @@ export async function applyAudioFxChain(
226232
const api = (
227233
window as unknown as {
228234
__HF_AUDIO_FX?: {
229-
render(p: Float32Array[], r: number, c: string): Promise<Float32Array[]>;
235+
render(
236+
p: Float32Array[],
237+
r: number,
238+
c: string,
239+
a?: string,
240+
): Promise<Float32Array[]>;
230241
};
231242
}
232243
).__HF_AUDIO_FX;
233244
if (!api) throw new Error("audio FX runtime failed to load");
234-
const out = await api.render(channelB64.map(decode), rate, chainJson);
245+
const out = await api.render(
246+
channelB64.map(decode),
247+
rate,
248+
chainJson,
249+
automationJson || undefined,
250+
);
235251
const encode = (plane: Float32Array): string => {
236252
const u8 = new Uint8Array(plane.buffer, plane.byteOffset, plane.length * 4);
237253
let s = "";
@@ -249,7 +265,8 @@ export async function applyAudioFxChain(
249265
),
250266
sampleRate,
251267
JSON.stringify(chain),
252-
] as [string[], number, string],
268+
options.automation ? serializeAutomation(options.automation) : "",
269+
] as [string[], number, string, string],
253270
)) as string[];
254271

255272
// byteOffset and byteLength matter: Node pools small allocations, so a
@@ -278,4 +295,4 @@ export async function applyAudioFxChain(
278295
}
279296
}
280297

281-
export type { HfAudioFxChain };
298+
export type { HfAudioFxChain, HfAutomation };

0 commit comments

Comments
 (0)