Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
164 commits
Select commit Hold shift + click to select a range
0d5abd2
feat(core): audio FX registry
vanceingalls Aug 1, 2026
8e0f63d
fix(core): stop declaring knobs that move nothing
vanceingalls Aug 4, 2026
f596128
feat(core): Web Audio graphs for the FX registry
vanceingalls Aug 1, 2026
c3291f2
fix(core): reverb level, phaser wiring, per-channel dynamics, one-pol…
vanceingalls Aug 4, 2026
3c33413
feat(engine): render audio FX in an OfflineAudioContext
vanceingalls Aug 1, 2026
6562969
feat(core): voiceover carve analysis
vanceingalls Aug 1, 2026
2fc689b
fix(engine): keep the FX render 16-bit, stereo, and correctly sized
vanceingalls Aug 4, 2026
17c654d
fix(producer): report an FX render failure as an audio error
vanceingalls Aug 4, 2026
535146b
test(engine): skip the browser FX render cases when there is no browser
vanceingalls Aug 12, 2026
38a9f6b
chore(engine): suppress the temp-file alert with the reason it is safe
vanceingalls Aug 12, 2026
f810040
feat(core): register the audio-fx-rack canary at 0%
vanceingalls Aug 12, 2026
80adff9
feat(studio): audio FX panel generated from the registry
vanceingalls Aug 1, 2026
92eb6e3
feat(studio): show the Audio FX section on audio tracks
vanceingalls Aug 1, 2026
aa172fb
refactor(studio): lift audioFxSummary out of PropertyPanelFlat
vanceingalls Aug 12, 2026
e3e3a41
feat(studio): put the audio FX rack behind its canary
vanceingalls Aug 12, 2026
153d0d3
feat(core,studio): hear the FX chain in preview, and run the carve an…
vanceingalls Aug 1, 2026
7e85bfb
fix(studio,core): stop parameter drags from restarting playback
vanceingalls Aug 1, 2026
78bce4d
fix(core): register FX worklets before building nodes that need them
vanceingalls Aug 2, 2026
486be53
refactor(studio): split the FX node row out of FxSection
vanceingalls Aug 2, 2026
fcf1a88
refactor(studio): lift the audio FX group out of PropertyPanelFlat
vanceingalls Aug 12, 2026
ab88116
feat(core): automation envelope model for audio tracks
vanceingalls Aug 2, 2026
d80cfde
feat(core): expose the AudioParams behind automatable FX knobs
vanceingalls Aug 2, 2026
e2153f3
feat(core): play automation envelopes in preview
vanceingalls Aug 2, 2026
ca93344
feat(engine): bake automation envelopes into the render
vanceingalls Aug 2, 2026
8b77ca0
fix(core): make the volume lane audible in preview
vanceingalls Aug 4, 2026
e84747c
fix(core): apply chain edits to the running graph
vanceingalls Aug 4, 2026
c000ecc
feat(studio): the geometry and plumbing behind automation lanes
vanceingalls Aug 4, 2026
85649d6
fix(studio): make automationLaneData reviewable, and evict one entry …
vanceingalls Aug 4, 2026
7bf71e8
fix(studio): carry the audio FX attributes onto every timeline row
vanceingalls Aug 4, 2026
4580abc
feat(studio): the automation lane itself
vanceingalls Aug 4, 2026
00a7031
fix(studio): let a track disclose its automation without a tween
vanceingalls Aug 4, 2026
be84bac
feat(studio): automate a parameter without reloading the preview
vanceingalls Aug 4, 2026
5295a35
fix(studio): stop the last two automation writes reloading the preview
vanceingalls Aug 4, 2026
4e94ad5
feat(studio): automate and un-automate each effect parameter
vanceingalls Aug 4, 2026
7554e95
feat(studio): only offer voiceover carve when there is a voice to car…
vanceingalls Aug 4, 2026
bb345bf
test(studio): cover carve visibility through the real element
vanceingalls Aug 4, 2026
47c147e
fix(studio): keep FX panel writes from clobbering each other
vanceingalls Aug 4, 2026
48c5dde
feat(engine): let an FX tail decay instead of cutting it at the clip
vanceingalls Aug 4, 2026
01cb7c0
feat(studio): curve, snap and type a value in an automation lane
vanceingalls Aug 4, 2026
bb9dd61
feat(engine): render audio FX in an OfflineAudioContext
vanceingalls Aug 1, 2026
e275c04
feat(core): voiceover carve analysis
vanceingalls Aug 1, 2026
a7d30a2
fix(engine): keep the FX render 16-bit, stereo, and correctly sized
vanceingalls Aug 4, 2026
f75e5a8
fix(producer): report an FX render failure as an audio error
vanceingalls Aug 4, 2026
384fdc4
test(engine): skip the browser FX render cases when there is no browser
vanceingalls Aug 12, 2026
7fdf4db
chore(engine): suppress the temp-file alert with the reason it is safe
vanceingalls Aug 12, 2026
7d79370
test(engine): probe ffmpeg and Chrome instead of assuming them
vanceingalls Aug 12, 2026
956ae2f
feat(core): register the audio-fx-rack canary at 0%
vanceingalls Aug 12, 2026
33961e6
feat(studio): audio FX panel generated from the registry
vanceingalls Aug 1, 2026
c3f2591
feat(studio): show the Audio FX section on audio tracks
vanceingalls Aug 1, 2026
33100d2
refactor(studio): lift audioFxSummary out of PropertyPanelFlat
vanceingalls Aug 12, 2026
d5794bf
feat(core,studio): hear the FX chain in preview, and run the carve an…
vanceingalls Aug 1, 2026
a2eabd2
fix(studio,core): stop parameter drags from restarting playback
vanceingalls Aug 1, 2026
550812a
feat(studio): put the audio FX rack behind its canary
vanceingalls Aug 12, 2026
cfc1215
fix(core): register FX worklets before building nodes that need them
vanceingalls Aug 2, 2026
cfafa22
refactor(studio): split the FX node row out of FxSection
vanceingalls Aug 2, 2026
cbf77bc
feat(core): automation envelope model for audio tracks
vanceingalls Aug 2, 2026
c2846a9
refactor(studio): lift the audio FX group out of PropertyPanelFlat
vanceingalls Aug 12, 2026
75057e2
feat(core): expose the AudioParams behind automatable FX knobs
vanceingalls Aug 2, 2026
380ed3d
feat(core): play automation envelopes in preview
vanceingalls Aug 2, 2026
db00f0d
fix(core): make the volume lane audible in preview
vanceingalls Aug 4, 2026
0f25e9c
feat(engine): bake automation envelopes into the render
vanceingalls Aug 2, 2026
33bb99f
fix(core): apply chain edits to the running graph
vanceingalls Aug 4, 2026
d9df8a1
fix(studio): drop the FX panel's dead __testables export
vanceingalls Aug 12, 2026
d21dfed
merge: pull in the FX panel dead-export fix
vanceingalls Aug 12, 2026
27fb8a8
merge: pull in the FX panel dead-export fix
vanceingalls Aug 12, 2026
43ca71e
merge: pull in the FX panel dead-export fix
vanceingalls Aug 12, 2026
c3399cd
merge: pull in the FX panel dead-export fix
vanceingalls Aug 12, 2026
7c410fd
fix(core,studio): clear the remaining Fallow audit findings on the FX…
vanceingalls Aug 12, 2026
1a2969a
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
05af338
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
ae55ede
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
3418148
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
439ba6c
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
f4b84cf
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
494d431
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
fec1957
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
3533460
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
7d72507
merge: pull in the fallow-audit fixes
vanceingalls Aug 12, 2026
55b20e6
Merge remote-tracking branch 'origin/main' into wa-4-fx-panel
vanceingalls Aug 12, 2026
6d11410
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
0d0d73d
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
644f9bf
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
ada3272
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
4816a1b
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
9328aee
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
1829d4c
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
9bedde7
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
2fb9588
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
a99d6d6
merge: pull in the main-catchup merge
vanceingalls Aug 12, 2026
dad13c8
fix(ci): allowlist the build-script consolidation in the no-main-dele…
vanceingalls Aug 12, 2026
7069323
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
e1011a8
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
3e2c259
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
556afc7
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
b9c7684
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
82f8d20
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
fe1ef0c
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
46efb15
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
dc951b9
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
4f1974e
merge: pull in the deletion-guard fix
vanceingalls Aug 12, 2026
6edd737
Merge remote-tracking branch 'origin/main' into wa-2-fx-preview
vanceingalls Aug 12, 2026
5ee0b17
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
6e3c8d1
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
fe19f99
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
0c16352
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
cc70cd1
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
df8d168
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
943e8d7
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
7da9de5
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
31f4862
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
7cf032d
fix(core): dedupe the wet/dry mix math between delayFeedback and chor…
vanceingalls Aug 12, 2026
0d92b36
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
9f6e04c
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
f951af6
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
43b03b6
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
4b068fb
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
ff6db14
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
c9c535c
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
b0998a8
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
edff77f
merge: pull in the wet/dry dedupe fix
vanceingalls Aug 12, 2026
13d13b9
Merge remote-tracking branch 'origin/main' into wa-3-automation-model
vanceingalls Aug 12, 2026
e98a651
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
be2fcbc
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
ed9f61e
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
17b6003
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
830d887
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
bd9083d
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
2a722ef
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
7f4f249
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
7ae0fd4
Merge remote-tracking branch 'origin/main' into wa-5-automation-runti…
vanceingalls Aug 12, 2026
b28e620
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
199dbeb
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
150e8f4
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
6f378b0
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
908d909
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
8870d86
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
76fbfcb
fix(core): remove the build-audio-fx-runtime.ts stray resurrected by …
vanceingalls Aug 12, 2026
0467055
merge: pull in the stray-file cleanup
vanceingalls Aug 12, 2026
a664bb8
merge: pull in the stray-file cleanup
vanceingalls Aug 12, 2026
4fe82ee
merge: pull in the stray-file cleanup
vanceingalls Aug 12, 2026
c96b429
merge: pull in the stray-file cleanup
vanceingalls Aug 12, 2026
16a471c
merge: pull in the stray-file cleanup
vanceingalls Aug 12, 2026
a4699e4
Merge remote-tracking branch 'origin/main' into wa-10-lane-component
vanceingalls Aug 12, 2026
7359e64
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
dac4f9e
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
44ec112
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
b886ae4
merge: pull in the main-merge conflict fix
vanceingalls Aug 12, 2026
7bd053a
fix(studio): pull TimelineLanes under the 600-line cap
vanceingalls Aug 12, 2026
6232f24
merge: pull in the file-size fix
vanceingalls Aug 12, 2026
c6f1db7
merge: pull in the file-size fix
vanceingalls Aug 12, 2026
f99bf5e
merge: pull in the file-size fix
vanceingalls Aug 12, 2026
2936314
merge: pull in the file-size fix
vanceingalls Aug 12, 2026
c03fd4a
fix(studio): split the extracted pointerdown handler under the CRAP t…
vanceingalls Aug 13, 2026
1a5bac7
merge: pull in the CRAP-score fix
vanceingalls Aug 13, 2026
a9f33b6
merge: pull in the CRAP-score fix
vanceingalls Aug 13, 2026
0cfd6fd
merge: pull in the CRAP-score fix
vanceingalls Aug 13, 2026
b35c9d8
merge: pull in the CRAP-score fix
vanceingalls Aug 13, 2026
0bdfaca
Merge remote-tracking branch 'origin/main' into wa-11-panel-writes
vanceingalls Aug 13, 2026
76703a8
merge: pull in the PR #3025 conflict fix
vanceingalls Aug 13, 2026
919e588
merge: pull in the PR #3025 conflict fix
vanceingalls Aug 13, 2026
d938c79
merge: pull in the PR #3025 conflict fix
vanceingalls Aug 13, 2026
cf02c1e
fix(studio): drop the unused DomEditSelection import in PropertyPanel…
vanceingalls Aug 13, 2026
50e48e1
merge: pull in the unused-import fix
vanceingalls Aug 13, 2026
144ae1a
merge: pull in the unused-import fix
vanceingalls Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@
"withLane",
],
},
// propertyPanelAutomation is the shared reader for both panel sections; the
// FX group that consumes these two lands one PR upstack, so a per-PR audit
// against the merge base sees them as unused.
{
"file": "packages/studio/src/components/editor/propertyPanelAutomation.ts",
"exports": ["automatedTargetsOf", "resolveAutomationRange"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@
"types": "./dist/audioFx.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-tail": {
"source": "./src/audio/audioFxTail.ts",
"runtime": "./dist/audio/audioFxTail.js",
"types": "./dist/audio/audioFxTail.d.ts",
"environments": ["browser", "bun", "node"]
},
"./audio-fx-runtime": {
"source": "./src/generated/audio-fx-runtime-inline.ts",
"runtime": "./dist/generated/audio-fx-runtime-inline.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@
"import": "./src/audioFx.ts",
"types": "./src/audioFx.ts"
},
"./audio-fx-tail": {
"bun": "./src/audio/audioFxTail.ts",
"node": "./dist/audio/audioFxTail.js",
"import": "./src/audio/audioFxTail.ts",
"types": "./src/audio/audioFxTail.ts"
},
"./audio-fx-runtime": {
"bun": "./src/generated/audio-fx-runtime-inline.ts",
"node": "./dist/generated/audio-fx-runtime-inline.js",
Expand Down Expand Up @@ -396,6 +402,10 @@
"import": "./dist/audioFx.js",
"types": "./dist/audioFx.d.ts"
},
"./audio-fx-tail": {
"import": "./dist/audio/audioFxTail.js",
"types": "./dist/audio/audioFxTail.d.ts"
},
"./audio-fx-runtime": {
"import": "./dist/generated/audio-fx-runtime-inline.js",
"types": "./dist/generated/audio-fx-runtime-inline.d.ts"
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/audio/audioFxTail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { chainTailSeconds, MAX_FX_TAIL_SECONDS } from "./audioFxTail.js";
import { synthesizeReverbImpulse } from "./audioFxGraph.js";
import type { HfAudioFxChain } from "../audioFx.js";
import type { HfAutomation } from "../audioAutomation.js";

const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });

describe("chainTailSeconds", () => {
it("is zero for a chain that settles with its input", () => {
expect(
chainTailSeconds(
chain([
{ type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1 } },
{ type: "compressor", id: "n2", params: { threshold: -18, ratio: 4, release: 9000 } },
// A 9-second release still holds no tail: with no input there is no
// signal to release, so the output is silence either way.
{ type: "phaser", id: "n3", params: { delay: 3, decay: 0.4, speed: 0.5 } },
]),
),
).toBe(0);
});

it("matches the reverb impulse it has to make room for", () => {
// The whole point: too short and the render cuts the tail the impulse
// generates. Measured against the generator rather than restating 0.6+2.6.
for (const size of [0.05, 0.4, 0.7, 1]) {
const impulse = synthesizeReverbImpulse(48000, size, 0.5);
const tail = chainTailSeconds(
chain([{ type: "reverb", id: "r", params: { size, damping: 0.5, wet: 0.35, dry: 0.7 } }]),
);
expect(tail).toBeCloseTo(impulse.length / 48000, 4);
}
});

it("counts a delay's repeats down to -60 dB", () => {
// 250 ms at 0.35 feedback: 0.35^7 = 6.4e-4, the first repeat under the floor.
expect(
chainTailSeconds(
chain([{ type: "delay", id: "d", params: { time: 250, feedback: 0.35, mix: 0.5 } }]),
),
).toBeCloseTo(1.75, 5);
});

it("sums a serial chain instead of taking the longest", () => {
// The delay hands each repeat to the room, so the last repeat still gets a
// full tail — taking the max would cut the end of it.
const both = chainTailSeconds(
chain([
{ type: "delay", id: "d", params: { time: 250, feedback: 0.35, mix: 0.5 } },
{ type: "reverb", id: "r", params: { size: 0.3, damping: 0.5, wet: 0.35, dry: 0.7 } },
]),
);
expect(both).toBeCloseTo(1.75 + (0.6 + 0.3 * 2.6), 5);
});

it("caps a tail the panel can dial but nobody wants to render", () => {
// 5 s between repeats at 0.95 feedback decays for eleven minutes.
expect(
chainTailSeconds(
chain([{ type: "delay", id: "d", params: { time: 5000, feedback: 0.95, mix: 1 } }]),
),
).toBe(MAX_FX_TAIL_SECONDS);
});

it("ignores an effect that is bypassed or mixed out", () => {
const bypassed = chain([
{
type: "reverb",
id: "r",
enabled: false,
params: { size: 1, damping: 0.5, wet: 1, dry: 0 },
},
]);
expect(chainTailSeconds(bypassed)).toBe(0);
const silent = chain([
{ type: "reverb", id: "r", params: { size: 1, damping: 0.5, wet: 0, dry: 1 } },
]);
expect(chainTailSeconds(silent)).toBe(0);
});

it("sizes the room for the loudest moment a lane reaches", () => {
// Static wet is 0 — read alone it would say "no tail" and cut the swell the
// lane brings in halfway through the clip.
const automation: HfAutomation = {
version: 1,
lanes: [
{
target: "fx.r.wet",
points: [
{ t: 0, v: 0 },
{ t: 2, v: 0.8 },
],
},
],
};
const withLane = chain([
{ type: "reverb", id: "r", params: { size: 0.5, damping: 0.5, wet: 0, dry: 1 } },
]);
expect(chainTailSeconds(withLane)).toBe(0);
expect(chainTailSeconds(withLane, automation)).toBeCloseTo(0.6 + 0.5 * 2.6, 5);
});
});
96 changes: 96 additions & 0 deletions packages/core/src/audio/audioFxTail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* How long a chain keeps ringing after its input stops.
*
* The render used to end the offline context at the last input sample, so a
* reverb or a delay was cut mid-tail — the one place the render did not match
* preview. The length is not a guess: every tail-producing effect here has a
* decay that follows from its own settings, so the render can ask for exactly
* the room it needs.
*/

import { fxAutomationTarget, type HfAutomation } from "../audioAutomation.js";
import { normalizeAudioFxParams, type HfAudioFxChain, type HfAudioFxNode } from "../audioFx.js";

/**
* Ceiling on the extension, in seconds.
*
* Delay is unbounded in principle: 5 s between repeats at 0.95 feedback decays
* for eleven minutes, and the panel can dial exactly that. A tail that outruns
* the composition costs render time and mixes into everything after it, so the
* chain gets the room it asks for up to here and is cut beyond it.
*/
export const MAX_FX_TAIL_SECONDS = 5;

/**
* Where a tail stops counting as audible: -60 dB below the signal that fed it,
* the usual convention for a reverb time. Anything quieter is under the noise
* floor of every codec this renders to.
*/
const TAIL_FLOOR = 0.001;

/**
* The largest value a knob reaches, over the whole clip.
*
* A lane's `curve` is an exponent, so a segment is monotone between its two
* points and cannot overshoot either — the maximum point value is the maximum
* of the lane, no sampling needed. Room size has to be sized for the loudest
* moment regardless of where in the clip it falls.
*/
function knobMax(node: HfAudioFxNode, key: string, automation?: HfAutomation): number {
// Normalised, so a knob missing from the attribute reads as its default and
// an out-of-range one is clamped the way the graph builder would clamp it.
const fallback = Number(normalizeAudioFxParams(node.type, node.params)[key] ?? 0);
if (!node.id || !automation) return Number.isFinite(fallback) ? fallback : 0;
const lane = automation.lanes.find((l) => l.target === fxAutomationTarget(node.id ?? "", key));
if (!lane || lane.points.length === 0) return Number.isFinite(fallback) ? fallback : 0;
return lane.points.reduce((max, p) => Math.max(max, p.v), -Infinity);
}

/**
* Repeats until a feedback loop falls under the floor, times the gap between
* them. `feedback` is capped below 1 by the registry, so this terminates.
*/
function delayTail(time: number, feedback: number): number {
const gap = Math.min(5, time / 1000);
if (gap <= 0) return 0;
const fb = Math.max(0, Math.min(0.999, feedback));
if (fb <= 0) return gap;
return Math.ceil(Math.log(TAIL_FLOOR) / Math.log(fb)) * gap;
}

/** One node's tail. Zero when it has none, or when it is mixed out entirely. */
function nodeTail(node: HfAudioFxNode, automation?: HfAutomation): number {
if (node.enabled === false) return 0;
switch (node.type) {
case "reverb":
// Exactly the generated impulse's length — see synthesizeReverbImpulse,
// which is the same expression. A convolution is as long as its impulse.
return knobMax(node, "wet", automation) > 0
? 0.6 + Math.max(0, Math.min(1, knobMax(node, "size", automation))) * 2.6
: 0;
case "delay":
return knobMax(node, "mix", automation) > 0
? delayTail(knobMax(node, "time", automation), knobMax(node, "feedback", automation))
: 0;
case "chorus":
// A single delay line, no feedback: it rings for one delay (≤100 ms).
return knobMax(node, "mix", automation) > 0 ? knobMax(node, "delay", automation) / 1000 : 0;
default:
// Everything else settles with its input. The phaser is an all-pass chain
// with no recirculation (group delay, not a tail); the dynamics nodes have
// long releases but no signal to release — silence in, silence out; a
// biquad rings for ~Q/f, which is microseconds.
return 0;
}
}

/**
* The whole chain's tail, in seconds.
*
* Summed, not maxed: the chain is serial, so a delay in front of a reverb hands
* each of its repeats to the room and the last one still gets a full tail.
*/
export function chainTailSeconds(chain: HfAudioFxChain, automation?: HfAutomation): number {
const total = chain.nodes.reduce((sum, node) => sum + nodeTail(node, automation), 0);
return Math.min(MAX_FX_TAIL_SECONDS, total);
}
22 changes: 13 additions & 9 deletions packages/core/stubs/audio-fx-runtime-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { scheduleChainAutomation } from "../src/audio/audioFxAutomation.js";
import { parseAutomation, resolveAutomation } from "../src/audioAutomation.js";
import { parseAudioFxChain, type HfAudioFxChain } from "../src/audioFx.js";
import { chainTailSeconds } from "../src/audio/audioFxTail.js";

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

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

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

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