Skip to content

Commit c0943b7

Browse files
committed
feat(engine): let an FX tail decay instead of cutting it at the clip
The offline render ended at the last input sample, so a reverb or a delay was still ringing when the context stopped. Measured on a 1.5 s tone through a default reverb, the render cut at 1.524 s while the tail was still at -29.7 dB — an audible chop, and the one place the render did not match preview. The length does not have to be guessed. Every tail here follows from its own settings: a convolution is exactly as long as its impulse, and `synthesizeReverbImpulse` derives that from room size; a delay's repeats fall by `feedback` every `time`, so the count down to -60 dB is a log. Everything else settles with its input — an all-pass chain has group delay, not a tail, and a 9-second compressor release has no signal to release once the clip stops. `chainTailSeconds` sums them (the chain is serial, so a delay in front of a reverb hands each repeat to the room), reads a lane's maximum rather than the static knob where one is automated, and caps at 5 s — 5 s between repeats at 0.95 feedback is eleven minutes of decay, and the panel can dial exactly that. The mixer's per-track atrim now allows the clip plus its tail; the atrim after apad still holds every track to the composition's length, so a tail can run over what follows but never extends the video. Same fixture after: a smooth decay to -72 dB, last non-zero sample at 3.306 s against the 3.4 s the settings predict.
1 parent 335d8a9 commit c0943b7

8 files changed

Lines changed: 337 additions & 11 deletions

File tree

‎packages/core/package-subpaths.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@
9292
"types": "./dist/audioFx.d.ts",
9393
"environments": ["browser", "bun", "node"]
9494
},
95+
"./audio-fx-tail": {
96+
"source": "./src/audio/audioFxTail.ts",
97+
"runtime": "./dist/audio/audioFxTail.js",
98+
"types": "./dist/audio/audioFxTail.d.ts",
99+
"environments": ["browser", "bun", "node"]
100+
},
95101
"./audio-fx-runtime": {
96102
"source": "./src/generated/audio-fx-runtime-inline.ts",
97103
"runtime": "./dist/generated/audio-fx-runtime-inline.js",

‎packages/core/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
"import": "./src/audioFx.ts",
107107
"types": "./src/audioFx.ts"
108108
},
109+
"./audio-fx-tail": {
110+
"bun": "./src/audio/audioFxTail.ts",
111+
"node": "./dist/audio/audioFxTail.js",
112+
"import": "./src/audio/audioFxTail.ts",
113+
"types": "./src/audio/audioFxTail.ts"
114+
},
109115
"./audio-fx-runtime": {
110116
"bun": "./src/generated/audio-fx-runtime-inline.ts",
111117
"node": "./dist/generated/audio-fx-runtime-inline.js",
@@ -386,6 +392,10 @@
386392
"import": "./dist/audioFx.js",
387393
"types": "./dist/audioFx.d.ts"
388394
},
395+
"./audio-fx-tail": {
396+
"import": "./dist/audio/audioFxTail.js",
397+
"types": "./dist/audio/audioFxTail.d.ts"
398+
},
389399
"./audio-fx-runtime": {
390400
"import": "./dist/generated/audio-fx-runtime-inline.js",
391401
"types": "./dist/generated/audio-fx-runtime-inline.d.ts"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
import { chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
3+
import { synthesizeReverbImpulse } from "./audioFxGraph.js";
4+
import type { HfAudioFxChain } from "../audioFx.js";
5+
import type { HfAutomation } from "../audioAutomation.js";
6+
7+
const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
8+
9+
describe("chainTailSeconds", () => {
10+
it("is zero for a chain that settles with its input", () => {
11+
expect(
12+
chainTailSeconds(
13+
chain([
14+
{ type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1 } },
15+
{ type: "compressor", id: "n2", params: { threshold: -18, ratio: 4, release: 9000 } },
16+
// A 9-second release still holds no tail: with no input there is no
17+
// signal to release, so the output is silence either way.
18+
{ type: "phaser", id: "n3", params: { delay: 3, decay: 0.4, speed: 0.5 } },
19+
]),
20+
),
21+
).toBe(0);
22+
});
23+
24+
it("matches the reverb impulse it has to make room for", () => {
25+
// The whole point: too short and the render cuts the tail the impulse
26+
// generates. Measured against the generator rather than restating 0.6+2.6.
27+
for (const size of [0.05, 0.4, 0.7, 1]) {
28+
const impulse = synthesizeReverbImpulse(48000, size, 0.5);
29+
const tail = chainTailSeconds(
30+
chain([{ type: "reverb", id: "r", params: { size, damping: 0.5, wet: 0.35, dry: 0.7 } }]),
31+
);
32+
expect(tail).toBeCloseTo(impulse.length / 48000, 4);
33+
}
34+
});
35+
36+
it("counts a delay's repeats down to -60 dB", () => {
37+
// 250 ms at 0.35 feedback: 0.35^7 = 6.4e-4, the first repeat under the floor.
38+
expect(
39+
chainTailSeconds(
40+
chain([{ type: "delay", id: "d", params: { time: 250, feedback: 0.35, mix: 0.5 } }]),
41+
),
42+
).toBeCloseTo(1.75, 5);
43+
});
44+
45+
it("sums a serial chain instead of taking the longest", () => {
46+
// The delay hands each repeat to the room, so the last repeat still gets a
47+
// full tail — taking the max would cut the end of it.
48+
const both = chainTailSeconds(
49+
chain([
50+
{ type: "delay", id: "d", params: { time: 250, feedback: 0.35, mix: 0.5 } },
51+
{ type: "reverb", id: "r", params: { size: 0.3, damping: 0.5, wet: 0.35, dry: 0.7 } },
52+
]),
53+
);
54+
expect(both).toBeCloseTo(1.75 + (0.6 + 0.3 * 2.6), 5);
55+
});
56+
57+
it("caps a tail the panel can dial but nobody wants to render", () => {
58+
// 5 s between repeats at 0.95 feedback decays for eleven minutes.
59+
expect(
60+
chainTailSeconds(
61+
chain([{ type: "delay", id: "d", params: { time: 5000, feedback: 0.95, mix: 1 } }]),
62+
),
63+
).toBe(MAX_FX_TAIL_SECONDS);
64+
});
65+
66+
it("ignores an effect that is bypassed or mixed out", () => {
67+
const bypassed = chain([
68+
{
69+
type: "reverb",
70+
id: "r",
71+
enabled: false,
72+
params: { size: 1, damping: 0.5, wet: 1, dry: 0 },
73+
},
74+
]);
75+
expect(chainTailSeconds(bypassed)).toBe(0);
76+
const silent = chain([
77+
{ type: "reverb", id: "r", params: { size: 1, damping: 0.5, wet: 0, dry: 1 } },
78+
]);
79+
expect(chainTailSeconds(silent)).toBe(0);
80+
});
81+
82+
it("sizes the room for the loudest moment a lane reaches", () => {
83+
// Static wet is 0 — read alone it would say "no tail" and cut the swell the
84+
// lane brings in halfway through the clip.
85+
const automation: HfAutomation = {
86+
version: 1,
87+
lanes: [
88+
{
89+
target: "fx.r.wet",
90+
points: [
91+
{ t: 0, v: 0 },
92+
{ t: 2, v: 0.8 },
93+
],
94+
},
95+
],
96+
};
97+
const withLane = chain([
98+
{ type: "reverb", id: "r", params: { size: 0.5, damping: 0.5, wet: 0, dry: 1 } },
99+
]);
100+
expect(chainTailSeconds(withLane)).toBe(0);
101+
expect(chainTailSeconds(withLane, automation)).toBeCloseTo(0.6 + 0.5 * 2.6, 5);
102+
});
103+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* How long a chain keeps ringing after its input stops.
3+
*
4+
* The render used to end the offline context at the last input sample, so a
5+
* reverb or a delay was cut mid-tail — the one place the render did not match
6+
* preview. The length is not a guess: every tail-producing effect here has a
7+
* decay that follows from its own settings, so the render can ask for exactly
8+
* the room it needs.
9+
*/
10+
11+
import { fxAutomationTarget, type HfAutomation } from "../audioAutomation.js";
12+
import { normalizeAudioFxParams, type HfAudioFxChain, type HfAudioFxNode } from "../audioFx.js";
13+
14+
/**
15+
* Ceiling on the extension, in seconds.
16+
*
17+
* Delay is unbounded in principle: 5 s between repeats at 0.95 feedback decays
18+
* for eleven minutes, and the panel can dial exactly that. A tail that outruns
19+
* the composition costs render time and mixes into everything after it, so the
20+
* chain gets the room it asks for up to here and is cut beyond it.
21+
*/
22+
export const MAX_FX_TAIL_SECONDS = 5;
23+
24+
/**
25+
* Where a tail stops counting as audible: -60 dB below the signal that fed it,
26+
* the usual convention for a reverb time. Anything quieter is under the noise
27+
* floor of every codec this renders to.
28+
*/
29+
const TAIL_FLOOR = 0.001;
30+
31+
/**
32+
* The largest value a knob reaches, over the whole clip.
33+
*
34+
* A lane's `curve` is an exponent, so a segment is monotone between its two
35+
* points and cannot overshoot either — the maximum point value is the maximum
36+
* of the lane, no sampling needed. Room size has to be sized for the loudest
37+
* moment regardless of where in the clip it falls.
38+
*/
39+
function knobMax(node: HfAudioFxNode, key: string, automation?: HfAutomation): number {
40+
// Normalised, so a knob missing from the attribute reads as its default and
41+
// an out-of-range one is clamped the way the graph builder would clamp it.
42+
const fallback = Number(normalizeAudioFxParams(node.type, node.params)[key] ?? 0);
43+
if (!node.id || !automation) return Number.isFinite(fallback) ? fallback : 0;
44+
const lane = automation.lanes.find((l) => l.target === fxAutomationTarget(node.id ?? "", key));
45+
if (!lane || lane.points.length === 0) return Number.isFinite(fallback) ? fallback : 0;
46+
return lane.points.reduce((max, p) => Math.max(max, p.v), -Infinity);
47+
}
48+
49+
/**
50+
* Repeats until a feedback loop falls under the floor, times the gap between
51+
* them. `feedback` is capped below 1 by the registry, so this terminates.
52+
*/
53+
function delayTail(time: number, feedback: number): number {
54+
const gap = Math.min(5, time / 1000);
55+
if (gap <= 0) return 0;
56+
const fb = Math.max(0, Math.min(0.999, feedback));
57+
if (fb <= 0) return gap;
58+
return Math.ceil(Math.log(TAIL_FLOOR) / Math.log(fb)) * gap;
59+
}
60+
61+
/** One node's tail. Zero when it has none, or when it is mixed out entirely. */
62+
function nodeTail(node: HfAudioFxNode, automation?: HfAutomation): number {
63+
if (node.enabled === false) return 0;
64+
switch (node.type) {
65+
case "reverb":
66+
// Exactly the generated impulse's length — see synthesizeReverbImpulse,
67+
// which is the same expression. A convolution is as long as its impulse.
68+
return knobMax(node, "wet", automation) > 0
69+
? 0.6 + Math.max(0, Math.min(1, knobMax(node, "size", automation))) * 2.6
70+
: 0;
71+
case "delay":
72+
return knobMax(node, "mix", automation) > 0
73+
? delayTail(knobMax(node, "time", automation), knobMax(node, "feedback", automation))
74+
: 0;
75+
case "chorus":
76+
// A single delay line, no feedback: it rings for one delay (≤100 ms).
77+
return knobMax(node, "mix", automation) > 0 ? knobMax(node, "delay", automation) / 1000 : 0;
78+
default:
79+
// Everything else settles with its input. The phaser is an all-pass chain
80+
// with no recirculation (group delay, not a tail); the dynamics nodes have
81+
// long releases but no signal to release — silence in, silence out; a
82+
// biquad rings for ~Q/f, which is microseconds.
83+
return 0;
84+
}
85+
}
86+
87+
/**
88+
* The whole chain's tail, in seconds.
89+
*
90+
* Summed, not maxed: the chain is serial, so a delay in front of a reverb hands
91+
* each of its repeats to the room and the last one still gets a full tail.
92+
*/
93+
export function chainTailSeconds(chain: HfAudioFxChain, automation?: HfAutomation): number {
94+
const total = chain.nodes.reduce((sum, node) => sum + nodeTail(node, automation), 0);
95+
return Math.min(MAX_FX_TAIL_SECONDS, total);
96+
}

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { scheduleChainAutomation } from "../src/audio/audioFxAutomation.js";
2020
import { parseAutomation, resolveAutomation } from "../src/audioAutomation.js";
2121
import { parseAudioFxChain, type HfAudioFxChain } from "../src/audioFx.js";
22+
import { chainTailSeconds } from "../src/audio/audioFxTail.js";
2223

2324
declare global {
2425
interface Window {
@@ -39,11 +40,11 @@ declare global {
3940
* Channel count is preserved: folding to mono here collapsed a stereo bed's
4041
* width for the render only, while preview kept it stereo.
4142
*
42-
* The context is exactly as long as the input. An effect with a tail — reverb,
43-
* delay — is still ringing at that point and is cut there, which is the one place
44-
* the render does not match preview. Extending it would lengthen the clip in the
45-
* mix, so how far a tail may run past a clip's end is a product decision rather
46-
* than something to pick here.
43+
* The context runs past the input by the chain's own tail, so a reverb or a
44+
* delay decays out instead of being cut at the last input sample. The length
45+
* comes from the settings (`chainTailSeconds`), capped, and the returned planes
46+
* are correspondingly longer than what came in — the mixer decides how much of
47+
* that it lets through past the clip's end.
4748
*/
4849

4950
/** The clip's audio as an AudioBuffer, a plane per channel. */
@@ -80,7 +81,11 @@ async function render(
8081
const chain: HfAudioFxChain = parseAudioFxChain(chainJson);
8182
const channels = Math.max(1, planes.length);
8283
const frames = planes[0]?.length ?? 0;
83-
const ctx = new OfflineAudioContext(channels, frames, sampleRate);
84+
const parsedAutomation = automationJson
85+
? resolveAutomation(parseAutomation(automationJson), chain)
86+
: null;
87+
const tail = Math.ceil(chainTailSeconds(chain, parsedAutomation ?? undefined) * sampleRate);
88+
const ctx = new OfflineAudioContext(channels, frames + tail, sampleRate);
8489

8590
if (chainNeedsWorklets(chain)) await ensureAudioFxWorklets(ctx);
8691

@@ -92,9 +97,8 @@ async function render(
9297
// The input WAV is the clip's own audio from its first sample, so clip-local
9398
// time is offline time — the envelope needs no offset here. Same scheduler as
9499
// preview, which is what makes the two agree.
95-
if (automationJson) {
96-
const automation = resolveAutomation(parseAutomation(automationJson), chain);
97-
scheduleChainAutomation(automation, chain, fx.nodes, {
100+
if (parsedAutomation) {
101+
scheduleChainAutomation(parsedAutomation, chain, fx.nodes, {
98102
scheduledAt: 0,
99103
elapsed: 0,
100104
rate: 1,

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ vi.mock("../utils/runFfmpeg.js", async (importOriginal) => {
4141
return { ...actual, runFfmpeg: runFfmpegMock };
4242
});
4343

44+
// The FX render drives a headless browser; the mix only needs to know the
45+
// processed file exists and how long a tail the chain asked for.
46+
const { applyAudioFxChainMock } = vi.hoisted(() => ({
47+
applyAudioFxChainMock: vi.fn(async (_src: string, _chain: unknown, outPath: string) => {
48+
const { writeFileSync } = await import("node:fs");
49+
writeFileSync(outPath, "stub");
50+
return outPath;
51+
}),
52+
}));
53+
54+
vi.mock("./audioFxRender.js", async (importOriginal) => {
55+
const actual = await importOriginal<typeof import("./audioFxRender.js")>();
56+
return { ...actual, applyAudioFxChain: applyAudioFxChainMock };
57+
});
58+
4459
vi.mock("../utils/ffprobe.js", async (importOriginal) => {
4560
const actual = await importOriginal<typeof import("../utils/ffprobe.js")>();
4661
return { ...actual, extractAudioMetadata: extractAudioMetadataMock };
@@ -60,6 +75,7 @@ describe("processCompositionAudio", () => {
6075
channels: 2,
6176
audioCodec: "aac",
6277
});
78+
applyAudioFxChainMock.mockClear();
6379
capturedFilterScripts.length = 0;
6480
for (const dir of tempDirs.splice(0)) {
6581
rmSync(dir, { recursive: true, force: true });
@@ -154,6 +170,81 @@ describe("processCompositionAudio", () => {
154170
expect(filter).not.toContain("weights=");
155171
});
156172

173+
it("lets an FX tail run past the clip, still bounded by the composition", async () => {
174+
// A reverb is still decaying when the clip's own audio stops. Trimming at
175+
// the clip boundary is what cut every tail short in the render.
176+
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
177+
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
178+
tempDirs.push(baseDir, workDir);
179+
writeFileSync(join(baseDir, "bed.wav"), "stub");
180+
181+
const result = await processCompositionAudio(
182+
[
183+
{
184+
id: "bed",
185+
src: "bed.wav",
186+
start: 0,
187+
end: 2,
188+
mediaStart: 0,
189+
layer: 0,
190+
volume: 1,
191+
type: "audio",
192+
fxChain: JSON.stringify({
193+
version: 1,
194+
nodes: [
195+
{ type: "reverb", id: "r", params: { size: 0.5, damping: 0.5, wet: 0.4, dry: 0.7 } },
196+
],
197+
}),
198+
},
199+
],
200+
baseDir,
201+
workDir,
202+
join(baseDir, "out.m4a"),
203+
8,
204+
);
205+
206+
expect(result.success).toBe(true);
207+
expect(applyAudioFxChainMock).toHaveBeenCalledTimes(1);
208+
const filter = capturedFilterScripts[capturedFilterScripts.length - 1];
209+
// 2 s clip + the 1.9 s tail 0.6 + size * 2.6 generates.
210+
expect(filter).toContain("atrim=0:3.9,");
211+
// And still cut at the composition's end, so a tail cannot extend the video.
212+
expect(filter).toContain("apad,atrim=0:8");
213+
});
214+
215+
it("cuts at the clip boundary when the chain has no tail", async () => {
216+
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
217+
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
218+
tempDirs.push(baseDir, workDir);
219+
writeFileSync(join(baseDir, "bed.wav"), "stub");
220+
221+
await processCompositionAudio(
222+
[
223+
{
224+
id: "bed",
225+
src: "bed.wav",
226+
start: 0,
227+
end: 2,
228+
mediaStart: 0,
229+
layer: 0,
230+
volume: 1,
231+
type: "audio",
232+
fxChain: JSON.stringify({
233+
version: 1,
234+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1 } }],
235+
}),
236+
},
237+
],
238+
baseDir,
239+
workDir,
240+
join(baseDir, "out.m4a"),
241+
8,
242+
);
243+
244+
const filter = capturedFilterScripts[capturedFilterScripts.length - 1];
245+
expect(filter).toContain("atrim=0:2,");
246+
});
247+
157248
it("compensates amix normalization so multi-track master gain equals track count", async () => {
158249
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
159250
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));

0 commit comments

Comments
 (0)