From 726fd1354f9499cd3ac6abc80c9542bec4f7c514 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:35:02 -0700 Subject: [PATCH 01/28] fix(engine): hand an empty audio track back instead of failing the render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg exits 0 and writes a structurally valid but EMPTY wav whenever a clip's trim starts past the end of its source — `-ss 10 -t 5` on a 2 s file. That track reached `new OfflineAudioContext(channels, 0, rate)`, which throws NotSupportedError, and the error travels past the mixer's per-track failure collector: one mis-set `data-media-start` on one clip with one enabled effect took the WHOLE render down with a RenderQualityError. An empty track has nothing to process, so applyAudioFxChain now hands the input back the way it already does for a chain with nothing enabled — before paying for a browser to decide it. The runtime stub guards the same case at the point the constructor would actually blow up, since it is reachable by any other caller of the injected script. The test falsifies against the real browser path: removing the guard reproduces `NotSupportedError: The number of frames provided (0) is less than the minimum bound (1)`. --- packages/core/stubs/audio-fx-runtime-entry.ts | 5 +++++ .../engine/src/services/audioFxRender.test.ts | 22 +++++++++++++++++++ packages/engine/src/services/audioFxRender.ts | 8 +++++++ 3 files changed, 35 insertions(+) diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index 81f8e5f035..d6a78b9439 100644 --- a/packages/core/stubs/audio-fx-runtime-entry.ts +++ b/packages/core/stubs/audio-fx-runtime-entry.ts @@ -81,6 +81,11 @@ async function render( const chain: HfAudioFxChain = parseAudioFxChain(chainJson); const channels = Math.max(1, planes.length); const frames = planes[0]?.length ?? 0; + // Nothing to process, and `new OfflineAudioContext(ch, 0, rate)` throws — an + // error the render treats as fatal. applyAudioFxChain screens empty tracks + // out before they reach the browser; this is the same guard at the point the + // constructor would actually blow up. + if (frames === 0) return planes; const parsedAutomation = automationJson ? resolveAutomation(parseAutomation(automationJson), chain) : null; diff --git a/packages/engine/src/services/audioFxRender.test.ts b/packages/engine/src/services/audioFxRender.test.ts index 6b07f35c3a..553333ee85 100644 --- a/packages/engine/src/services/audioFxRender.test.ts +++ b/packages/engine/src/services/audioFxRender.test.ts @@ -253,3 +253,25 @@ describe("browser render", () => { expect(readWav(outPath).samples.length).toBeGreaterThan(0); }, 180_000); }); + +/** + * ffmpeg exits 0 and writes a structurally valid but EMPTY wav whenever a + * clip's trim starts past the end of its source (`-ss 10 -t 5` on a 2 s file). + * That track used to reach `new OfflineAudioContext(ch, 0, rate)`, which throws + * — and the error travels past the mixer's per-track failure collector, so one + * mis-set `data-media-start` took the whole render down. + */ +describe("an empty track", () => { + it("is handed back untouched rather than failing the render", async () => { + const input = join(dir, "empty.wav"); + writeWav(input, new Float32Array(0), SR); + const output = join(dir, "out.wav"); + + // Returns the input path, the same contract as a chain with nothing enabled + // — and without paying for a browser to decide it. + await expect( + applyAudioFxChain(input, chainOf("peaking"), output, { trackId: "t" }), + ).resolves.toBe(input); + expect(existsSync(output)).toBe(false); + }); +}); diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index 1d53156621..5e9f62c0d3 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -194,6 +194,14 @@ export async function applyAudioFxChain( const { samples, sampleRate, channels } = readWav(inputWav); const planes = deinterleave(samples, channels); + // An empty track has nothing to process — and an OfflineAudioContext of zero + // length throws, which is fatal for the WHOLE render rather than this track: + // the error travels past the mixer's per-track failure collector. ffmpeg + // writes an empty but structurally valid WAV whenever a clip's trim starts + // past the end of its source, so one mis-set `data-media-start` used to take + // the render down. Guarded here as well as in the runtime so an empty track + // never costs a browser. + if ((planes[0]?.length ?? 0) === 0) return inputWav; // Audio processing needs no GPU or special capture mode; a plain sandboxed // browser is enough, and the lease pool reuses one across tracks. From e5fbac48b439033fdda2e7d7dea94943f10c9664 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:37:16 -0700 Subject: [PATCH 02/28] fix(engine): take the browser lease inside the try that releases it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lease was acquired above the try, with `mkdtempSync` between the two — so a failure there (a full disk, a read-only tmpdir) returned without ever reaching the `finally` that releases it. A leaked pooled browser is worse than the error that caused it: a pool with no leases left hangs every later render instead of failing one. The temp dir is now taken first and the lease inside the try, with the release made conditional. The abort check moves above the acquire too, so an already-cancelled track no longer takes a browser out of the pool just to hand it straight back. No test: the fix is the ordering of two resource acquisitions, and the only way to observe it is to make mkdtempSync throw, which needs an injection seam that does not exist here and would be more risk than the three lines it guards. --- packages/engine/src/services/audioFxRender.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index 5e9f62c0d3..8d0248b9f0 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -203,17 +203,21 @@ export async function applyAudioFxChain( // never costs a browser. if ((planes[0]?.length ?? 0) === 0) return inputWav; - // Audio processing needs no GPU or special capture mode; a plain sandboxed - // browser is enough, and the lease pool reuses one across tracks. - const lease = await acquireBrowser([ - "--no-sandbox", - "--autoplay-policy=no-user-gesture-required", - ]); + // Both resources are taken INSIDE the try that releases them. The lease used + // to be acquired above it, with the mkdtemp between — so a failure there + // (a full disk, a read-only tmpdir) leaked a pooled browser, and a pool with + // no leases left hangs every later render rather than failing one. const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-")); + let lease: Awaited> | null = null; try { + // Audio processing needs no GPU or special capture mode; a plain sandboxed + // browser is enough, and the lease pool reuses one across tracks. + // Checked before the lease, not after: an already-cancelled track has no + // reason to take a browser out of the pool just to hand it straight back. if (options.signal?.aborted) { throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`); } + lease = await acquireBrowser(["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]); const page = await lease.browser.newPage(); try { // AudioWorklet is only exposed in a secure context, and about:blank is @@ -299,7 +303,7 @@ export async function applyAudioFxChain( ); } finally { rmSync(hostDir, { recursive: true, force: true }); - await lease.release().catch(() => undefined); + await lease?.release().catch(() => undefined); } } From 213c5f67156d890def2a92e19c5bdd2889208498 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:40:19 -0700 Subject: [PATCH 03/28] fix(core): aim the phaser's trim lanes at the trims, not at the pinned wet/dry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `in_gain` and `out_gain` trim the signal entering and leaving the phaser, and apply() drives them through inTrim/outTrim while pinning wet and dry to 1. The automation map aimed both lanes at wet.gain and dry.gain instead — so an envelope on either knob modulated a constant, the trim it was supposed to move stayed wherever apply() last left it, and any values-only edit re-ran apply() and slammed wet/dry back to 1 over the running ramp. Heard as: "fade the phaser to silence" ends at full dry signal. Wrong in preview and in the render, since both share this builder. The builder's own comment records that wiring these knobs to wet/dry was already found wrong once and moved to the trims; that refactor updated apply() and missed the map. The existing exposure test only asserted that SOME param was present under each key, which a mis-aimed target passes — the new test asserts by value, which is the only thing a lane actually cares about. --- packages/core/src/audio/audioFxGraph.test.ts | 23 ++++++++++++++++++++ packages/core/src/audio/audioFxGraph.ts | 9 ++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 9c54f12bc9..4d85eca285 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -438,3 +438,26 @@ describe("automatable parameters", () => { expect(onePole.automation?.frequency).toBeUndefined(); }); }); + +describe("phaser automation targets", () => { + /** + * `in_gain` and `out_gain` trim the signal entering and leaving the effect, + * which the builder drives through inTrim/outTrim while pinning wet and dry + * to 1. The automation map used to aim both lanes at wet/dry — so an envelope + * modulated a constant, the trim it was supposed to move stayed frozen, and + * "fade the phaser out" left the dry leg playing at full level. + * + * Asserted by VALUE rather than by node identity: the trims are internal, and + * the only honest question is whether the param a lane would drive is the one + * the knob sets. + */ + it("drives the trims a lane is named for, not the pinned wet/dry pair", () => { + const handle = buildFxNode(ctx() as unknown as BaseAudioContext, "phaser", { + ...defaultAudioFxParams("phaser"), + in_gain: 0.25, + out_gain: 0.5, + }); + expect(handle.automation?.in_gain?.[0]?.param.value).toBeCloseTo(0.25, 6); + expect(handle.automation?.out_gain?.[0]?.param.value).toBeCloseTo(0.5, 6); + }); +}); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index a366b823e7..abb1f2e5f0 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -360,8 +360,13 @@ const allpassPhaser: Builder = (ctx, p) => { // frequency at once — not one knob, one param — so they stay unautomated. automation: { speed: [{ param: lfo.frequency }], - in_gain: [{ param: dry.gain }], - out_gain: [{ param: wet.gain }], + // The trims, not wet/dry. apply() drives inTrim/outTrim from these knobs + // and pins wet and dry to 1 — so a lane aimed at wet/dry modulated a + // constant and left the trim frozen, and the next values-only edit slammed + // it back over the running envelope. The comment above records that this + // wiring was already moved once; the automation map was missed. + in_gain: [{ param: inTrim.gain }], + out_gain: [{ param: outTrim.gain }], }, dispose: () => { try { From f83b20e1428414a0575dfb87f3d38b701888719b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:42:14 -0700 Subject: [PATCH 04/28] fix(core): move a node's id with its slot when the chain updates in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reordering two effects of the same type leaves `shapeOf` identical — it carries type, poles and the one-pole frequency, but no id — so the chain updates in place instead of rebuilding. That is right for the audio: the params move with the position. The ids did not move with them. They were captured once at build time, so after a reorder each handle named whichever effect used to sit in that slot, and scheduleChainAutomation built its byId map from those stale names: a lane on `fx.n2.frequency` drove the band that is now n1. This is the invariant HfAudioFxNode.id documents itself as protecting — "reordering the chain never re-points a lane at a different effect" — and it is easiest to hit on the all-peaking chains the voiceover carve produces. Preview only, since the render rebuilds from scratch, so the symptom is preview quietly ceasing to predict the render. Fixed by following the id, not by adding it to the shape: a same-type reorder genuinely does not need a rebuild, only a correct id map. --- packages/core/src/audio/audioFxGraph.test.ts | 28 ++++++++++++++++++++ packages/core/src/audio/audioFxGraph.ts | 12 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 4d85eca285..1a8d235196 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -461,3 +461,31 @@ describe("phaser automation targets", () => { expect(handle.automation?.out_gain?.[0]?.param.value).toBeCloseTo(0.5, 6); }); }); + +describe("chain update keeps ids with their effects", () => { + const band = (id: string, frequency: number) => ({ + type: "peaking", + id, + enabled: true, + params: { ...defaultAudioFxParams("peaking"), frequency }, + }); + + /** + * Reordering two effects of the same type leaves the shape string identical, + * so the chain updates in place rather than rebuilding — correct for the + * audio, since the params move with the position. The ids have to move too: + * a lane addresses its effect by id, and an id captured at build time names + * whichever effect used to occupy that slot. The scheduler would then drive + * `fx.n2.frequency` into the band that is now n1 — the exact swap that + * HfAudioFxNode.id documents itself as preventing, and the one the voiceover + * carve's all-peaking chains make easy to hit. + */ + it("moves an id with its slot when same-type effects are reordered", () => { + const chain: HfAudioFxChain = { version: 1, nodes: [band("n1", 200), band("n2", 4000)] }; + const handle = buildFxChain(ctx() as unknown as BaseAudioContext, chain); + + const swapped: HfAudioFxChain = { version: 1, nodes: [band("n2", 4000), band("n1", 200)] }; + expect(handle.update(swapped)).toBe(true); + expect(handle.nodes.map((n) => n.id)).toEqual(["n2", "n1"]); + }); +}); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index abb1f2e5f0..09f3c91a7a 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -515,7 +515,17 @@ export function buildFxChain(ctx: BaseAudioContext, chain: HfAudioFxChain): FxCh if (shapeOf(next) !== shape) return false; const active = next.nodes.filter((node) => node.enabled !== false); active.forEach((node, i) => { - handles[i]?.handle.update(normalizeAudioFxParams(node.type, node.params)); + const held = handles[i]; + if (!held) return; + held.handle.update(normalizeAudioFxParams(node.type, node.params)); + // The id follows the position, because the params just did. Reordering + // two effects of the same type leaves the shape identical, so the graph + // is updated in place — but a lane addresses its effect BY id, and an id + // captured at build time then names whichever effect used to be here. + // The scheduler would drive `fx.n2.frequency` into the band that is now + // n1: exactly what HfAudioFxNode.id documents itself as preventing. + if (node.id === undefined) delete held.id; + else held.id = node.id; }); shape = shapeOf(next); return true; From 2fb80b1da0c4e1ce2056cd27bcda1d7499406f01 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:44:38 -0700 Subject: [PATCH 05/28] fix(core): sample the volume lane at clip-local time, the way the render bakes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lane's `t` is "seconds from the start of the clip" — HfAutomationPoint says so, and the render honours it: prepareAudioTrack cuts the wav with `-ss mediaStart`, so its t=0 IS the clip's start, and normaliseEnvelope subtracts trackStart. Preview fed `relTime` instead, which is MEDIA time: it carries mediaStart, scales by playbackRate and wraps on a loop. So the same envelope played somewhere else than it rendered. `data-media-start` past the last point held the final value from the first frame and never faded; `playbackRate: 2` ran the envelope at double speed in preview only; a looping clip restarted the envelope every lap in preview while the render baked it once. The FX lanes shipped in this same stack already use clip-local elapsed. There is one time base for automation, and this makes the volume lane use it. --- packages/core/src/runtime/media.test.ts | 31 +++++++++++++++++++++++++ packages/core/src/runtime/media.ts | 10 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index c6fa207ed8..bf697161b8 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -365,6 +365,37 @@ describe("syncRuntimeMedia", () => { expect(only).toBeCloseTo(0.55, 5); }); + /** + * The render bakes the lane at CLIP-LOCAL time: prepareAudioTrack already + * cut the wav with `-ss mediaStart`, so its t=0 is the clip's start, and + * normaliseEnvelope subtracts trackStart. Preview used to sample at MEDIA + * time — mediaStart included, scaled by playbackRate, wrapped on a loop — so + * the same envelope played somewhere else than it rendered. + */ + it("samples the lane at clip-local time, the way the render bakes it", () => { + const trimmed = (t: number) => { + const clip = createMockClip({ start: 0, end: 10, volume: 0.55, mediaStart: 30 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + clip.el.setAttribute("data-automation", DUCK); + let seen = -1; + syncRuntimeMedia({ + clips: [clip], + timeSeconds: t, + playing: true, + playbackRate: 1, + onElementVolume: (_el, v) => { + seen = v; + }, + }); + return seen; + }; + // `data-media-start="30"` on a clip whose lane holds 0.8 until t=2 then + // ducks to 0.1 by t=3. At media time the playhead is already 30 s past the + // last point, so preview held 0.1 from the first frame and never ducked. + expect(trimmed(1)).toBeCloseTo(0.8, 5); + expect(trimmed(5)).toBeCloseTo(0.1, 5); + }); + it("supersedes keyframes probed from the timeline", () => { // Both present: the lane is the explicit one, and `lint` warns about it. const clip = createMockClip({ start: 0, end: 10, volume: 0.55 }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 8dabe8c37b..a6aa4c2183 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -274,7 +274,15 @@ export function syncRuntimeMedia(params: { // An explicit volume lane owns the fader. It is checked before the probed // keyframes because the two would otherwise fight, and it is the one the // author drew — `lint` warns when a track carries both. - const laneGain = elementVolumeLaneGain(el, relTime); + // Clip-local, NOT `relTime`. A lane's `t` is "seconds from the start of + // the clip" (see HfAutomationPoint), and the render honours that: the wav + // is already cut with `-ss mediaStart`, so its t=0 IS the clip's start. + // `relTime` is MEDIA time — it carries mediaStart, scales by playbackRate + // and wraps on a loop — so feeding it here played the envelope at a + // different position than it renders, or ran it off the end entirely on a + // trimmed clip. The FX lanes on this same feature use clip-local elapsed; + // there is one time base, and this is it. + const laneGain = elementVolumeLaneGain(el, params.timeSeconds - clip.start); if (laneGain !== null) { authorVolume = clampVolume(laneGain); } else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) { From 480c43e31e56e00ec3ac33f02e6520041c2fd7ed Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:48:40 -0700 Subject: [PATCH 06/28] fix(studio): let an FX parameter be typed, and stop a bare blur re-persisting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures with one root: the row never tracked whether a gesture actually edited anything. The number field could not be typed into. It was bound to the committed value, while every keystroke was clamped into range and written live — and a live write does not refresh the prop, so React put the old number straight back. Typing 5000 into a 20..20000 knob wrote 20 on the first keystroke and got no further; the knob could only be dragged. Worse, `Number("") === 0` passes Number.isFinite, so select-all + Delete before retyping instantly live-wrote the parameter MINIMUM — 20 Hz on a cutoff, -40 dB on a gain. The field now holds what is being typed as text, and an empty field is a state on the way to a number rather than a number to clamp and persist. `commit()` hangs off pointerup, keyup and blur, all reachable with no edit — clicking a slider thumb without moving it, or tabbing through the field. It unconditionally wrote `latest`, which was seeded at mount and only ever written by an edit. So: open an EQ row, run the carve or press undo, then click the slider and release without moving it, and the row silently re-persisted its mount-time number over the change. It also fired a full persisting write — source patch, selection resync, preview reload, audio restart — for a gesture that moved nothing. Each fix is falsified by deletion. A fourth guard (resyncing `latest` on every inbound value) was dropped rather than kept: the edit flag subsumes it, and no test could be made to fail without it. --- .../editor/propertyPanelFxControls.test.tsx | 115 ++++++++++++++++++ .../editor/propertyPanelFxControls.tsx | 45 ++++++- 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFxControls.test.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx b/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx new file mode 100644 index 0000000000..e498875822 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFxControls.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FxParamRow } from "./propertyPanelFxControls"; +import type { HfAudioFxNumberParam } from "@hyperframes/core/audio-fx"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** A frequency knob: wide range, so a clamp to the minimum is unmistakable. */ +const FREQUENCY: HfAudioFxNumberParam = { + kind: "number", + key: "frequency", + label: "Frequency", + min: 20, + max: 20000, + step: 1, + default: 1000, + unit: "Hz", +}; + +/** React tracks its own value on the node, so a plain assignment is ignored. */ +function setInputValue(input: HTMLInputElement, text: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, text); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function mount(value: number) { + const onChange = vi.fn(); + const onCommit = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + act(() => { + root = createRoot(host); + root.render( + , + ); + }); + const number = () => host.querySelector(".hf-fx-number")!; + const slider = () => host.querySelector(".hf-fx-slider")!; + const type = (text: string) => { + act(() => { + number().focus(); + setInputValue(number(), text); + }); + }; + /** A new value arrives through the prop — a carve, or an undo. */ + const receive = (next: number) => { + act(() => { + root.render( + , + ); + }); + }; + return { host, number, slider, onChange, onCommit, type, receive }; +} + +describe("FxParamRow number field", () => { + it("shows what is being typed instead of snapping back to the stored value", () => { + // Every keystroke is clamped and written live, and the live write does not + // refresh the prop — so a field bound to the committed value put the old + // number straight back. Typing 5000 wrote 20 on the first keystroke and the + // knob could not be typed into at all, only dragged. + const { number, type } = mount(1000); + type("5"); + expect(number().value).toBe("5"); + type("5000"); + expect(number().value).toBe("5000"); + }); + + it("does not write the parameter minimum while the field is empty", () => { + // `Number("") === 0` passes Number.isFinite, so select-all + Delete before + // retyping used to clamp to the minimum and live-write it — 20 Hz here. + const { onChange, type } = mount(1000); + type(""); + expect(onChange).not.toHaveBeenCalled(); + type("800"); + expect(onChange).toHaveBeenLastCalledWith("frequency", 800); + }); +}); + +describe("FxParamRow commit", () => { + it("stays quiet when a gesture changed nothing", () => { + // commit() hangs off pointerup, keyup and blur, all reachable without an + // edit. Firing then costs a source patch, a selection resync, a preview + // reload and an audio restart for a gesture that moved nothing. + const { slider, number, onCommit } = mount(1000); + act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true }))); + act(() => number().dispatchEvent(new Event("blur", { bubbles: true }))); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("does not re-persist a stale value over a change that arrived meanwhile", () => { + // The scenario: open an EQ row, run the carve (or press undo), which + // rewrites the chain so this row's value becomes 400. Then click the slider + // thumb and release without moving it. `latest` was seeded at mount and only + // written by an edit, so it still held 1000 — and the release wrote it back, + // undoing the carve with no gesture that looks like an edit. + const { slider, onCommit, type, receive } = mount(1000); + // An edit happened earlier in this row's life, so `latest` holds 1000. + type("1000"); + act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true }))); + onCommit.mockClear(); + + receive(400); + act(() => slider().dispatchEvent(new Event("pointerup", { bubbles: true }))); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFxControls.tsx b/packages/studio/src/components/editor/propertyPanelFxControls.tsx index 4fd3254bdb..63d7bfd6da 100644 --- a/packages/studio/src/components/editor/propertyPanelFxControls.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxControls.tsx @@ -132,6 +132,26 @@ export function FxParamRow({ * write applied on the way down. */ const [pending, setPending] = useState(null); + /** + * What the number field is showing while it has focus. + * + * The field cannot be bound to the committed value: every keystroke is + * clamped into range and written live, and the live write does not refresh + * the prop — so React put the old number straight back and typing `5000` into + * a 20..20000 knob wrote 20 on the first keystroke and never got further. Held + * as TEXT, so a half-typed "-" or "" is a state the field can be in rather + * than a number to clamp and persist. + */ + const [typing, setTyping] = useState(null); + /** + * Did this gesture actually change anything? + * + * `commit()` hangs off pointerup, keyup and blur, all of which fire without + * an edit — clicking a slider thumb without moving it, or tabbing through the + * field. Committing then re-persisted whatever `latest` happened to hold and + * silently reverted any change that had arrived meanwhile. + */ + const edited = useRef(false); useEffect(() => { if (!dragging) setLocal(value); }, [value, dragging]); @@ -146,6 +166,7 @@ export function FxParamRow({ const p = param as HfAudioFxNumberParam; const next = Math.min(p.max, Math.max(p.min, raw)); latest.current = next; + edited.current = true; setLocal(next); onChange(param.key, next); }, @@ -154,6 +175,12 @@ export function FxParamRow({ const commit = useCallback(() => { setDragging(false); + // Nothing was edited, so there is nothing to persist. Without this a bare + // focus/blur — or a click on the slider thumb that never moved — fired a + // full persisting write: source patch, selection resync, preview reload and + // an audio restart, for a gesture that changed no value. + if (!edited.current) return; + edited.current = false; if (typeof latest.current === "number") setPending(latest.current); onCommit?.(param.key, latest.current); }, [onCommit, param.key]); @@ -228,13 +255,25 @@ export function FxParamRow({ min={param.min} max={param.max} step={param.step} - value={display(param, current)} + value={typing ?? display(param, current)} disabled={locked} + onFocus={() => setTyping(display(param, current))} onChange={(e) => { - const next = Number(e.target.value); + const text = e.target.value; + setTyping(text); + // An empty field, a lone "-", or a trailing "." are all states on the + // way to a number, not numbers. `Number("")` is 0, which passes + // Number.isFinite — so clearing the field to retype used to clamp to + // the parameter MINIMUM and write it live: 20 Hz on a cutoff, -40 dB + // on a gain. + if (text.trim() === "") return; + const next = Number(text); if (Number.isFinite(next)) handleNumber(next); }} - onBlur={commit} + onBlur={() => { + commit(); + setTyping(null); + }} onKeyDown={(e) => { if (e.key === "Enter") commit(); }} From c9c2d3f6aafaad240d268f649b5030e3f60da5c9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:50:36 -0700 Subject: [PATCH 07/28] fix(lint): stop the double-automation rule blaming the wrong element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern allowed `[^;]{0,200}` between an element's selector and `volume:`, and `[^;]` crosses `)`, `,` and newlines. A chained GSAP timeline has no semicolon until the end of the whole chain, so gsap.timeline().to("#bgm", { x: 10 }).to("#vo", { volume: 1 }); matched for `#bgm` — an element whose only automation is the lane. The warning then told the author "the lane wins, the tween is ignored" about a tween that is on a different track, and its fixHint invited them to delete the lane that was actually working. Refusing to cross the closing paren keeps the match inside the call the selector belongs to. It costs a false negative when another value in the same object is a call result, which is the safe direction for a rule that only warns and, by its own comment, already only guesses. The `s` flag went with it: inert, since the character class already matched newlines. --- packages/lint/src/rules/media.test.ts | 21 +++++++++++++++++++++ packages/lint/src/rules/media.ts | 9 ++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts index f2574aa178..8554bad69d 100644 --- a/packages/lint/src/rules/media.test.ts +++ b/packages/lint/src/rules/media.test.ts @@ -457,6 +457,27 @@ describe("audio_volume_double_automation", () => { } }); + it("does not blame the wrong element in a chained timeline", async () => { + // A chain has no semicolon until its very end, so a run that could cross `)` + // reached the `volume` in a LATER call and reported the element from an + // earlier one. Acting on the fixHint would have deleted #bgm's only real + // automation to fix a tween that is on #vo. + const res = await lintHyperframeHtml( + withScript( + LANE, + `gsap.timeline().to("#bgm", { duration: 0.6, x: 10 }).to("#vo", { volume: 1 });`, + ), + ); + expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(false); + }); + + it("still catches a real tween further down the same call", async () => { + const res = await lintHyperframeHtml( + withScript(LANE, `gsap.timeline().to("#bgm", { duration: 0.6, ease: "none", volume: 0 });`), + ); + expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(true); + }); + it("ignores a lane that automates something other than volume", async () => { const res = await lintHyperframeHtml( withScript( diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index 9efca4f6fe..32c10d8620 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -625,7 +625,14 @@ function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFin // the same call the runtime's own probe would pick up, and the rule only // warns, so a miss costs nothing. const escaped = escapeRegExp(id); - const tweened = new RegExp(`#${escaped}(?![\\w-])[^;]{0,200}?\\bvolume\\s*:`, "s").test(script); + // `[^;)]`, not `[^;]`: a chained timeline has no semicolon until the end of + // the whole chain, so a run that could cross `)` matched `volume` in a LATER + // `.to()` call and named the wrong element — and this rule's fixHint tells + // the author to delete their lane. Refusing to cross the closing paren keeps + // the match inside the call the selector belongs to. It costs a false + // negative when some other value in the same object is a call result, which + // is the safe direction for a warning that already only guesses. + const tweened = new RegExp(`#${escaped}(?![\\w-])[^;)]{0,200}?\\bvolume\\s*:`).test(script); if (!tweened) continue; findings.push({ code: "audio_volume_double_automation", From 1b57a95441882b18e70d3009094f51401fa3f88d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 10:52:36 -0700 Subject: [PATCH 08/28] build(core): regenerate the audio-fx runtime before the tests run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/generated/audio-fx-runtime-inline.ts` is what the engine injects into the headless page to do the actual DSP, and nothing kept it in step with its sources. Its sibling has `check:position-edits-render`, which rebuilds and then `git diff --exit-code`s the artifact — but that form needs the file tracked, and this one is gitignored. Measured tonight while fixing the zero-frame guard: editing the stub and re-running the engine's render tests injected the PREVIOUS bundle, so the fix appeared not to work and the old DSP ran with nothing reporting the drift. That is the exact preview/render divergence this stack exists to prevent, aimed at whoever is developing it. Making `test` regenerate it is the version of the gate that works without touching .gitignore: the artifact cannot be stale when the tests read it. Not covered: a fresh clone still cannot resolve `@hyperframes/core/audio-fx-runtime` from `packages/engine` until core has been built at least once. CI survives on build ordering. Tracking the artifact, or a pretest that builds core from the engine, are both bigger calls than this one. --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 06ac920aa8..29f6a2a87d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -549,7 +549,7 @@ }, "scripts": { "build": "bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts", - "test": "bun run check:position-edits-render && vitest run", + "test": "bun run check:position-edits-render && bun run build:audio-fx-runtime && vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:runtime-coverage": "vitest run --coverage src/runtime", From 40679e915cb4e0930b5855c2c9d7169a0bbde610 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:06:20 -0700 Subject: [PATCH 09/28] fix(core): dispose a clip's FX graph when it ends, not only when it is stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ended` listener spliced the source out of `_activeSources` and restored the element's muted flag, but never disposed the FX handle — and the splice is what made it unreachable, because `stopAll()` disposes by walking that same array. So every clip that finished naturally leaked its graph for the rest of the session. The leak is not just memory. Each stale handle keeps a MutationObserver bound to `data-fx-chain`/`data-automation`, so every later panel edit runs rebuild() against a dead source: a fresh graph per abandoned clip, including a ~576 KB reverb impulse and chorus/phaser oscillators that are started and never stopped. Play a five-clip composition three times and fifteen observers answer every subsequent knob turn. At this point in the stack `attachElementFxChain` has no chainless early return — it watches every audio clip, chain or not — so this leaked for all of them, not only the ones carrying effects. Disposal is skipped when the entry was already gone: `stopAll()` disposes its own, and `stop()` is what fires this event. --- .../core/src/runtime/webAudioTransport.test.ts | 13 +++++++++++++ packages/core/src/runtime/webAudioTransport.ts | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 15f1825a13..f1597ccc8b 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -388,6 +388,19 @@ describe("WebAudioTransport", () => { expect(transport.isActive()).toBe(false); }); + it("disposes the FX graph when a clip ends naturally", async () => { + // stopAll() disposes by walking _activeSources, and the splice above had + // already removed this entry — so the handle, its MutationObserver and any + // running LFO survived the clip for the rest of the session. + const { transport, mock, gen } = setupTransport(100); + await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen); + + mock.sourceNode._fireEnded(); + + expect(mock.sourceNode.disconnect).toHaveBeenCalled(); + expect(mock.gainNode.disconnect).toHaveBeenCalled(); + }); + it("registers onended listener on the sourceNode", async () => { const { transport, mock, gen } = setupTransport(100); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index fedcdca0bf..2ce3ea4bba 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -243,6 +243,21 @@ export class WebAudioTransport { if (idx !== -1) { this._activeSources.splice(idx, 1); el.muted = priorMuted; + // The graph goes with it. Splicing alone left the FX handle alive and + // then UNREACHABLE — stopAll() disposes by walking this array, which + // the splice just emptied of this entry. Every clip that finished + // naturally leaked its MutationObserver for the session, and each one + // still answered later `data-fx-chain` edits by rebuilding a whole + // graph (impulse response, chorus/phaser oscillators started and never + // stopped) around a dead source. Not disposed when idx is -1: stopAll() + // has already done it, and `stop()` is what fired this event. + try { + sourceNode.disconnect(); + fx?.dispose(); + gainNode.disconnect(); + } catch { + // Already torn down. + } if (this._activeSources.length === 0) this._paused = true; } }); From 48729561dc336da196ba78dd6e809dfc43a8e069 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:10:55 -0700 Subject: [PATCH 10/28] fix(studio): lock the FX rack while the carve is measuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `analyse` captures the chain and the automation before its fetch and decode, then rewrites the whole `data-fx-chain` from that snapshot. Anything committed during those seconds — adding an effect, moving a knob — landed first and was silently discarded when the analysis returned. Only the Analyse control was gated on `analysing`; every other control in the rack stayed live throughout. FxSection already accepts `disabled` and threads it to every row; the carve panel simply never passed it. Refusing the edit for the duration is the honest answer — merging it into a measurement that did not account for it is not, and last-write-wins across an async gap is what this was. The test had to stub OfflineAudioContext as well as fetch: without a constructor, `analyse` returns before the decode and the window under test never opens, which is why the existing carve tests never saw this. Does not cover a write arriving from somewhere other than this panel — a timeline lane edit, or an undo — during the same gap. That needs the write to re-read rather than the reader to lock. --- .../editor/propertyPanelAudioFxGroup.test.tsx | 63 +++++++++++++++++++ .../editor/propertyPanelAudioFxGroup.tsx | 8 +++ 2 files changed, 71 insertions(+) diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx index 9c508668d7..16986e6ee8 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -1456,3 +1456,66 @@ describe("AudioFxGroup carve against a deleted voice", () => { ).toEqual(["fx.k1.frequency"]); }); }); + +describe("AudioFxGroup while the carve is measuring", () => { + /** + * `analyse` captures the chain and the automation before its fetch and decode, + * then rewrites the whole `data-fx-chain` from that snapshot. An effect added + * — or a knob committed — during those seconds landed first and was silently + * discarded when the analysis returned. Only the Analyse control was gated; + * every other control in the rack stayed live throughout. + */ + it("locks the rack, so an edit cannot be made against a snapshot that is moving", async () => { + // A fetch that never settles holds the panel in its analysing state, which + // is exactly the window the race lives in. + const hang = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", hang); + // happy-dom has no Web Audio, and without a constructor `analyse` returns + // before it ever reaches the decode — closing the window under test. + vi.stubGlobal( + "OfflineAudioContext", + class { + decodeAudioData() { + return new Promise(() => {}); + } + }, + ); + try { + const bed = document.createElement("audio"); + bed.id = "bed"; + bed.setAttribute("src", "bed.wav"); + document.body.append(bed); + const voice = document.createElement("audio"); + voice.id = "narration"; + voice.setAttribute("src", "vo.wav"); + document.body.append(voice); + + const host = document.createElement("div"); + document.body.append(host); + await act(async () => { + createRoot(host).render( + , + ); + }); + + // The bed carves itself against its one candidate, which starts the + // decode — and the whole rack goes read-only until it lands. + expect(hang).toHaveBeenCalled(); + const controls = Array.from(host.querySelectorAll(".hf-fx-slider")); + expect(controls.length).toBeGreaterThan(0); + expect(controls.every((c) => c.disabled)).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 33b9f162ce..c900214f09 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -609,6 +609,14 @@ export function AudioFxGroup({ return ( Date: Sat, 8 Aug 2026 11:12:58 -0700 Subject: [PATCH 11/28] fix(engine): give the ffmpeg children the signal that actually aborts them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal AbortController exists so a fatal FX error can cancel in-flight ffmpeg before the finally-block deletes workDir. It reached the entry guard and applyAudioFxChain, and nothing else: the trim, the video extract and the download were all still handed the CALLER's signal. So `internalController .abort()` cancelled nothing, and the `rmSync(workDir, { recursive: true })` immediately after it ran while those children were still writing into the directory. Concretely: track A's FX render fails while track B is mid-trim, with no external cancellation. B's ffmpeg keeps writing `${id}-trimmed.wav` into an unlinked directory until it finishes. The comment above the controller describes a mechanism that was never wired to the processes it names. The download also had no signal at all, though downloadToTemp accepts one. No test: the three changes are which variable is passed to a child process helper, and observing the difference means driving real ffmpeg to the point of cancellation. The old wiring's inertness is provable by reading — the caller's signal is never aborted by this function — and the new wiring is the same three calls with the controller the function already built. --- packages/engine/src/services/audioMixer.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 369562e328..8a2dff3135 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -765,6 +765,12 @@ export async function processCompositionAudio( // be able to abort the in-flight ffmpeg runs before the finally-block removes // workDir out from under them. Chained off the caller's signal so external // cancellation still behaves as before. + // + // Every child that can outlive a sibling's failure has to be given THIS + // signal, not the caller's: the trim, the video extract and the download all + // took `signal`, so `internalController.abort()` cancelled nothing and the + // `rmSync(workDir)` on the next line ran while their ffmpeg children were + // still writing into it. const internalController = new AbortController(); const effectiveSignal = internalController.signal; if (signal) { @@ -795,7 +801,7 @@ export async function processCompositionAudio( if (isHttpUrl(srcPath)) { try { - srcPath = await downloadToTemp(srcPath, workDir); + srcPath = await downloadToTemp(srcPath, workDir, undefined, effectiveSignal); } catch (err: unknown) { failures.push( downloadFailure(err instanceof Error ? err.message : String(err), element.id), @@ -842,7 +848,7 @@ export async function processCompositionAudio( startTime: element.mediaStart, duration: element.end - element.start, }, - signal, + effectiveSignal, config, ); if (!extractResult.success) { @@ -868,7 +874,7 @@ export async function processCompositionAudio( trimmedPath, element.mediaStart, element.end - element.start, - signal, + effectiveSignal, config, ); if (!prepResult.success) { From 32f9f01081c1ec8da5ced9bc000b25b3fe316bb0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:44:53 -0700 Subject: [PATCH 12/28] fix(engine): duck the FX output before quantising it, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `writeWav` clamps and quantises the chain's float output to int16, and the mixer only ran `applyVolumeEnvelopeToWav` over that file afterwards. So a chain that overshoots full scale was destructively clipped even when the volume lane immediately pulled the track 12 dB down — audible distortion baked into the render that preview, working in float throughout, never had. Measured on 12 s of narration through a +12 dB peaking band into saturation with the lane holding the track at -12 dB: the chain peaks at 1.346, so the old order sheared 19,166 samples (1.7%) flat against ±1 before ducking. Level matched, the two renders differ by an error signal 26.3 dB below the audio, worst single-sample delta 0.242. The envelope now travels into `applyAudioFxChain` and lands on the float planes before `writeWav` sees them; the mixer runs its own pass only for a track the FX pass did not bake. `writeWav`'s clamp stays — it is the correct last resort for a signal still hot after the duck, just no longer the first thing that happens. The per-frame segment-cursor gain walk is extracted as `createEnvelopeWalker` so both bakers share one implementation, and the ffmpeg-expression fallback for non-int16 WAVs is untouched. `applyAudioFxChain` now returns `{ path, envelopeBaked }` rather than a bare path: inferring the bake from `path !== inputWav` would silently break on the two early returns (empty chain, empty track). The new test is stereo with a step in the envelope, so it also falsifies a per-plane walk — restarting the cursor for a second channel hands it the tail gain for its whole length. Falsified against three mutations: clamp-then-duck (0.5 instead of 0.9), envelope dropped (1.0), plane-outer walk (0.449). Co-Authored-By: Claude Opus 5 (1M context) --- .../engine/src/services/audioFxRender.test.ts | 79 ++++++++++++++++++- packages/engine/src/services/audioFxRender.ts | 59 ++++++++++++-- .../engine/src/services/audioMixer.test.ts | 70 ++++++++++++++-- packages/engine/src/services/audioMixer.ts | 52 +++++++----- .../src/services/audioVolumeEnvelope.ts | 53 +++++++++---- 5 files changed, 264 insertions(+), 49 deletions(-) diff --git a/packages/engine/src/services/audioFxRender.test.ts b/packages/engine/src/services/audioFxRender.test.ts index 553333ee85..cccd3d5328 100644 --- a/packages/engine/src/services/audioFxRender.test.ts +++ b/packages/engine/src/services/audioFxRender.test.ts @@ -140,7 +140,7 @@ describe("applyAudioFxChain", () => { join(dir, "out.wav"), { trackId: "t" }, ); - expect(out).toBe(input); + expect(out).toEqual({ path: input, envelopeBaked: false }); expect(existsSync(join(dir, "out.wav"))).toBe(false); }); @@ -173,7 +173,7 @@ describe("browser render", () => { outPath, { trackId: "t" }, ); - expect(result).toBe(outPath); + expect(result).toEqual({ path: outPath, envelopeBaked: false }); const before = readWav(input).samples; const after = readWav(outPath).samples; expect(after.length).toBe(before.length); @@ -243,6 +243,79 @@ describe("browser render", () => { expect(tail).toBeGreaterThan(head + 15); }, 180_000); + it("ducks a hot chain before quantising it, not after", async () => { + // A +6 dB peaking band on a 0.9 tone leaves the chain output around 1.8 — + // well past full scale — and the volume lane immediately halves it. Baking + // the envelope into the float samples lands that at ~0.9 intact. Letting + // writeWav clamp first and ducking the file afterwards lands at ~0.5 with + // the tops sheared off: distortion the render bakes in and preview, which + // is float all the way through, never has. + // + // Stereo with a step partway through, so the same run also proves the + // envelope is walked per frame across all channels rather than per channel: + // one walker restarted for a second plane would hand it the tail gain for + // its whole length. + const input = join(dir, "hot-in.wav"); + const frames = Math.floor(SR * 0.3); + const s = new Float32Array(frames * 2); + for (let i = 0; i < frames; i++) { + const v = 0.9 * Math.sin((2 * Math.PI * 440 * i) / SR); + s[i * 2] = v; + s[i * 2 + 1] = v; + } + writeWav(input, s, SR, 2); + + const outPath = join(dir, "hot-out.wav"); + const result = await applyAudioFxChain( + input, + { + version: 1, + nodes: [{ type: "peaking", enabled: true, params: { frequency: 440, gain: 6, q: 1 } }], + }, + outPath, + { + trackId: "t", + envelope: { + // 0.5 for the first 150 ms, then 0.25 — the step is a segment + // advance, which is what the walker's cursor exists for. + keyframes: [ + { time: 0, volume: 0.5 }, + { time: 0.15, volume: 0.5 }, + { time: 0.1501, volume: 0.25 }, + { time: 0.3, volume: 0.25 }, + ], + trackStart: 0, + baseVolume: 1, + }, + }, + ); + expect(result.envelopeBaked).toBe(true); + + const out = readWav(outPath); + expect(out.channels).toBe(2); + const channel = (c: number): Float32Array => + Float32Array.from({ length: frames }, (_, i) => out.samples[i * 2 + c] ?? 0); + const window = (s: Float32Array, from: number, to: number): Float32Array => + s.slice(Math.floor(from * SR), Math.floor(to * SR)); + + for (const c of [0, 1]) { + const plane = channel(c); + // Past the filter's settling transient, before the step. + const loud = window(plane, 0.1, 0.14); + const peak = Math.max(...Array.from(loud, Math.abs)); + // ~0.9. Clamped-then-ducked gives 0.5; a dropped envelope gives 1.0; a + // walker restarted per plane gives channel 1 the 0.25 tail gain. + expect(peak).toBeGreaterThan(0.8); + expect(peak).toBeLessThan(0.95); + // And still a sine, not a squared-off one: clipping 1.8 down to 1.0 pulls + // the crest factor from 1.41 towards 1.15. + expect(peak / rms(loud)).toBeGreaterThan(1.35); + // The step landed, so the envelope was sampled over time, not once. + const quiet = window(plane, 0.2, 0.29); + expect(Math.max(...Array.from(quiet, Math.abs))).toBeCloseTo(peak / 2, 1); + } + }, 180_000); + it("renders a multi-effect chain including reverb", async () => { const input = join(dir, "in.wav"); tone(input); @@ -271,7 +344,7 @@ describe("an empty track", () => { // — and without paying for a browser to decide it. await expect( applyAudioFxChain(input, chainOf("peaking"), output, { trackId: "t" }), - ).resolves.toBe(input); + ).resolves.toEqual({ path: input, envelopeBaked: false }); expect(existsSync(output)).toBe(false); }); }); diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index 8d0248b9f0..f1da45b093 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -20,6 +20,8 @@ import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime"; import { enabledAudioFxNodes, type HfAudioFxChain } from "@hyperframes/core/audio-fx"; import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation"; import { acquireBrowser } from "./browserManager.js"; +import { createEnvelopeWalker } from "./audioVolumeEnvelope.js"; +import type { AudioVolumeKeyframe } from "./audioMixer.types.js"; export class AudioFxRenderError extends Error { constructor(message: string) { @@ -172,10 +174,37 @@ function interleave(planes: readonly Float32Array[]): Float32Array { return out; } +/** + * Multiply every channel by the envelope, in place, one gain per frame. + * + * Frame-outer rather than plane-outer on purpose: the walker's cursor only + * moves forward, so restarting each channel at t=0 would hand the second one + * the tail gain for its whole length. + */ +function applyEnvelopeToPlanes( + planes: readonly Float32Array[], + sampleRate: number, + gainAt: (time: number) => number, +): void { + const frames = planes[0]?.length ?? 0; + for (let frame = 0; frame < frames; frame += 1) { + const gain = gainAt(frame / sampleRate); + for (const plane of planes) plane[frame] = (plane[frame] ?? 0) * gain; + } +} + /** * Run a chain over `inputWav`, writing `outputWav`. Resolves to the path to use - * downstream: `outputWav` when the chain did something, `inputWav` untouched - * when the chain was empty. + * downstream — `outputWav` when the chain did something, `inputWav` untouched + * when the chain was empty — plus whether the volume envelope was baked here. + * + * The envelope is applied to the float samples the chain produced, BEFORE + * `writeWav` quantises them. Leaving it to the mixer's second pass meant a + * chain that overshoots full scale was destructively clipped to ±1 and only + * then ducked, so a track the lane pulls 12 dB down still rendered the + * distortion — which preview, working in float throughout, never had. + * `writeWav`'s clamp stays: it is the correct last resort for a signal that is + * still hot after the duck. * * Failure is fatal to the caller rather than a soft per-track warning: quietly * rendering the dry signal ships a mix that sounds plausible and is not what @@ -185,9 +214,14 @@ export async function applyAudioFxChain( inputWav: string, chain: HfAudioFxChain, outputWav: string, - options: { trackId: string; signal?: AbortSignal; automation?: HfAutomation }, -): Promise { - if (enabledAudioFxNodes(chain).length === 0) return inputWav; + options: { + trackId: string; + signal?: AbortSignal; + automation?: HfAutomation; + envelope?: { keyframes: AudioVolumeKeyframe[]; trackStart: number; baseVolume: number }; + }, +): Promise<{ path: string; envelopeBaked: boolean }> { + if (enabledAudioFxNodes(chain).length === 0) return { path: inputWav, envelopeBaked: false }; if (!existsSync(inputWav)) { throw new AudioFxRenderError(`Audio FX input is missing: ${inputWav}`); } @@ -201,7 +235,7 @@ export async function applyAudioFxChain( // past the end of its source, so one mis-set `data-media-start` used to take // the render down. Guarded here as well as in the runtime so an empty track // never costs a browser. - if ((planes[0]?.length ?? 0) === 0) return inputWav; + if ((planes[0]?.length ?? 0) === 0) return { path: inputWav, envelopeBaked: false }; // Both resources are taken INSIDE the try that releases them. The lease used // to be acquired above it, with the mkdtemp between — so a failure there @@ -291,8 +325,19 @@ export async function applyAudioFxChain( if (outPlanes.length === 0 || (outPlanes[0]?.length ?? 0) === 0) { throw new AudioFxRenderError(`Audio FX produced no samples for track ${options.trackId}`); } + // Null when the keyframes normalise away to nothing — then the mixer's + // own paths still own this track's gain, so say so rather than claiming + // a bake that never happened. + const gainAt = options.envelope + ? createEnvelopeWalker( + options.envelope.keyframes, + options.envelope.trackStart, + options.envelope.baseVolume, + ) + : null; + if (gainAt) applyEnvelopeToPlanes(outPlanes, sampleRate, gainAt); writeWav(outputWav, interleave(outPlanes), sampleRate, outPlanes.length); - return outputWav; + return { path: outputWav, envelopeBaked: gainAt !== null }; } finally { await page.close().catch(() => undefined); } diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 957016d593..895fb0574e 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -44,11 +44,15 @@ vi.mock("../utils/runFfmpeg.js", async (importOriginal) => { // The FX render drives a headless browser; the mix only needs to know the // processed file exists and how long a tail the chain asked for. const { applyAudioFxChainMock } = vi.hoisted(() => ({ - applyAudioFxChainMock: vi.fn(async (_src: string, _chain: unknown, outPath: string) => { - const { writeFileSync } = await import("node:fs"); - writeFileSync(outPath, "stub"); - return outPath; - }), + applyAudioFxChainMock: vi.fn( + async (_src: string, _chain: unknown, outPath: string, options?: { envelope?: unknown }) => { + const { writeFileSync } = await import("node:fs"); + writeFileSync(outPath, "stub"); + // The real one bakes the volume envelope into its float output, so the + // mixer must not run its own pass afterwards. + return { path: outPath, envelopeBaked: Boolean(options?.envelope) }; + }, + ), })); vi.mock("./audioFxRender.js", async (importOriginal) => { @@ -212,6 +216,62 @@ describe("processCompositionAudio", () => { expect(filter).toContain("apad,atrim=0:8"); }); + it("hands the volume envelope to the FX pass instead of ducking the file after it", async () => { + // The FX pass writes 16-bit PCM, so a chain that overshoots full scale is + // clipped there. Ducking afterwards bakes that distortion in even though + // the lane pulls the track well down; the envelope has to travel into the + // FX pass and land on its float output. + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "voice.wav"), "stub"); + + const result = await processCompositionAudio( + [ + { + id: "voice", + src: "voice.wav", + start: 2, + end: 5, + mediaStart: 0, + layer: 0, + volume: 0.4, + volumeKeyframes: [ + { time: 2, volume: 1 }, + { time: 5, volume: 0.25 }, + ], + type: "audio", + fxChain: JSON.stringify({ + version: 1, + nodes: [{ type: "peaking", id: "p", params: { frequency: 440, gain: 12, q: 1 } }], + }), + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 5, + ); + + expect(result.success).toBe(true); + expect(applyAudioFxChainMock).toHaveBeenCalledTimes(1); + expect(applyAudioFxChainMock.mock.calls[0]?.[3]).toMatchObject({ + envelope: { + keyframes: [ + { time: 2, volume: 1 }, + { time: 5, volume: 0.25 }, + ], + trackStart: 2, + baseVolume: 0.4, + }, + }); + + // And the mixer trusts that bake: unity gain, no second pass, no ffmpeg + // volume expression re-applying the same envelope on top of it. + const filter = capturedFilterScripts[capturedFilterScripts.length - 1]; + expect(filter).not.toContain(":eval=frame"); + }); + it("cuts at the clip boundary when the chain has no tail", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 8a2dff3135..9ef9cb602f 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -907,6 +907,27 @@ export async function processCompositionAudio( ) : null; + // Computed before the chain runs, not after: the FX pass bakes the + // envelope into its float output so the duck lands before the ±1 clamp + // in writeWav, instead of after it. + // + // A volume lane supersedes keyframes probed from the timeline: the two + // would fight, and the lane is the explicit one. `lint` warns when a + // track carries both. + const laneKeyframes = automation + ? volumeLaneKeyframes(automation, element.start, element.end - element.start) + : null; + const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes; + const envelope = + envelopeKeyframes && envelopeKeyframes.length > 0 + ? { + keyframes: envelopeKeyframes, + trackStart: element.start, + baseVolume: element.volume ?? 1.0, + } + : null; + + let bakedEnvelope = false; let tailSeconds = 0; if (element.fxChain) { // The chain is serialised into the attribute, the same way colour @@ -916,7 +937,7 @@ export async function processCompositionAudio( // The rendered WAV is longer than the input by exactly this much, so // the mix has to be told to let it through. tailSeconds = chainTailSeconds(chain, automation ?? undefined); - audioSrcPath = await applyAudioFxChain( + const fxResult = await applyAudioFxChain( audioSrcPath, chain, join(workDir, `${element.id}-fx.wav`), @@ -924,29 +945,24 @@ export async function processCompositionAudio( trackId: element.id, signal: effectiveSignal, ...(automation ? { automation } : {}), + ...(envelope ? { envelope } : {}), }, ); + audioSrcPath = fxResult.path; + bakedEnvelope = fxResult.envelopeBaked; } - // Primary volume-automation path: bake the envelope into the PCM samples - // (sample-accurate, no keyframe ceiling). If the WAV isn't the expected - // 16-bit PCM, fall back to the ffmpeg expression path by leaving the - // keyframes on the track for buildVolumeExpression to handle. - // - // A volume lane supersedes keyframes probed from the timeline: the two - // would fight, and the lane is the explicit one. `lint` warns when a - // track carries both. - const laneKeyframes = automation - ? volumeLaneKeyframes(automation, element.start, element.end - element.start) - : null; - const envelopeKeyframes = laneKeyframes ?? element.volumeKeyframes; - let bakedEnvelope = false; - if (envelopeKeyframes && envelopeKeyframes.length > 0) { + // Primary volume-automation path for a track the FX pass did not bake: + // multiply the envelope into the PCM samples (sample-accurate, no + // keyframe ceiling). If the WAV isn't the expected 16-bit PCM, fall + // back to the ffmpeg expression path by leaving the keyframes on the + // track for buildVolumeExpression to handle. + if (envelope && !bakedEnvelope) { bakedEnvelope = applyVolumeEnvelopeToWav( audioSrcPath, - envelopeKeyframes, - element.start, - element.volume ?? 1.0, + envelope.keyframes, + envelope.trackStart, + envelope.baseVolume, ); } tracks.push({ diff --git a/packages/engine/src/services/audioVolumeEnvelope.ts b/packages/engine/src/services/audioVolumeEnvelope.ts index 9d4d0aa730..08f8a18828 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.ts @@ -74,6 +74,40 @@ function parseWavLayout(buffer: Buffer): WavLayout | null { }; } +/** + * A gain lookup that walks forward through the envelope with a segment cursor, + * so a whole track costs O(N+M) rather than O(N×M). `interpolateVolumeGain` + * restarts from segment 0 on every call — fine for the preview path (once per + * RAF tick), not for a per-sample walk over 48k×duration frames. + * + * The cursor only ever advances, so callers must pass non-decreasing times. + * Returns null when the keyframes normalise to nothing, which the callers read + * as "no automation here". + */ +export function createEnvelopeWalker( + keyframes: AudioVolumeKeyframe[], + trackStart: number, + baseVolume: number, +): ((time: number) => number) | null { + const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume); + const first = envelope[0]; + if (!first) return null; + + let segment = 0; + return (time: number): number => { + for (;;) { + const next = envelope[segment + 1]; + if (segment >= envelope.length - 2 || !next || time < next.time) break; + segment += 1; + } + const a = envelope[segment] ?? first; + const b = envelope[segment + 1] ?? a; + const span = b.time - a.time; + const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span)); + return a.volume + (b.volume - a.volume) * progress; + }; +} + /** * Multiply a prepared WAV's samples by a time-varying gain envelope in place. * @@ -86,8 +120,8 @@ export function applyVolumeEnvelopeToWav( trackStart: number, baseVolume: number, ): boolean { - const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume); - if (envelope.length === 0) return false; + const gainAt = createEnvelopeWalker(keyframes, trackStart, baseVolume); + if (!gainAt) return false; try { const buffer = readFileSync(wavPath); @@ -99,21 +133,8 @@ export function applyVolumeEnvelopeToWav( const frameBytes = numChannels * bytesPerSample; const frameCount = Math.floor(dataSize / frameBytes); - // Maintain an incremental segment cursor so the per-frame envelope lookup - // is O(N+M) overall, not O(N×M). interpolateVolumeGain restarts from 0 on - // each call — fine for the preview path (one call per RAF tick) but not for - // the PCM path (one call per sample, 48k×duration frames total). - let segment = 0; for (let frame = 0; frame < frameCount; frame += 1) { - const time = frame / sampleRate; - while (segment < envelope.length - 2 && time >= envelope[segment + 1]!.time) segment += 1; - - const a = envelope[segment]!; - const b = envelope[segment + 1] ?? a; - const span = b.time - a.time; - const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span)); - const gain = a.volume + (b.volume - a.volume) * progress; - + const gain = gainAt(frame / sampleRate); const base = dataOffset + frame * frameBytes; for (let channel = 0; channel < numChannels; channel += 1) { const at = base + channel * bytesPerSample; From d9229b5ab69327a72ef64582c746def75d81d962 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:50:36 -0700 Subject: [PATCH 13/28] fix(engine): move a track's PCM across the wire in bounded chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole track crossed in one `page.evaluate` pair — ~184 MB of base64 for a 3-minute stereo 48 kHz clip, in a single WebSocket frame each way. puppeteer-core caps its frames at 256 MB (NodeWebSocketTransport, verified in the installed copy) and the browser pool does not pass `pipe: true`, so stereo 48 kHz past ~8.7 minutes raised AudioFxRenderError and failed the render. V8's max string length is a second wall not much beyond it. Both were fatal: an FX failure takes the whole mix down rather than dropping one track. Input and output now move 8 MiB at a time, staying separate byte arrays page-side rather than being concatenated into one string, so neither the frame cap nor the string limit ever sees the whole track. That also bounds the peak Node-side allocation: `processCompositionAudio` renders tracks through an unbounded `Promise.all`, so every track's payload used to be live at once. The page drops the input chunks before the render allocates its buffers, so it no longer holds two copies of the track. Also drops the `Array.from(u8.subarray(...))` in the encoder, which boxed every byte of each 32 KiB window into a JS array for nothing — `apply` takes array-likes. The 8.7-minute ceiling itself has no test: reproducing it needs a 256 MB payload. What is tested is the assembly, which is where chunking actually goes wrong — a 45 s stereo ramp spanning two chunks per plane, checked for length, for values at the seam, and for monotonicity across all 2.16 M frames. Falsified against three mutations: chunks reassembled in reverse (1.75 off at the seam), the trailing partial chunk dropped (short by 125,696 frames), and an unbounded input subarray (long by 125,696). Co-Authored-By: Claude Opus 5 (1M context) --- .../engine/src/services/audioFxRender.test.ts | 60 +++++++ packages/engine/src/services/audioFxRender.ts | 164 ++++++++++++------ 2 files changed, 175 insertions(+), 49 deletions(-) diff --git a/packages/engine/src/services/audioFxRender.test.ts b/packages/engine/src/services/audioFxRender.test.ts index cccd3d5328..bd78710c83 100644 --- a/packages/engine/src/services/audioFxRender.test.ts +++ b/packages/engine/src/services/audioFxRender.test.ts @@ -316,6 +316,66 @@ describe("browser render", () => { } }, 180_000); + it("round-trips a track larger than one transfer chunk", async () => { + // The PCM used to cross in a single evaluate pair, so a stereo track past + // ~8.7 minutes blew puppeteer's 256 MB frame cap and failed the render. + // It now goes a chunk at a time; this clip is 45 s stereo, so each plane + // spans two chunks and the seam is inside the audio rather than at its end. + // + // A ramp, not a tone: every sample is a unique position marker, so a chunk + // dropped, reordered or over-read shows up as a value at the wrong place + // instead of hiding inside a periodic signal. + const input = join(dir, "long-in.wav"); + const frames = SR * 45; + const at = (i: number): number => -0.9 + (1.8 * i) / (frames - 1); + const s = new Float32Array(frames * 2); + for (let i = 0; i < frames; i++) { + s[i * 2] = at(i); + s[i * 2 + 1] = -at(i); + } + writeWav(input, s, SR, 2); + + const outPath = join(dir, "long-out.wav"); + await applyAudioFxChain( + input, + // Transparent: a peaking band at 0 dB is unity, so the output is the + // input and any difference is the transfer's doing. + { + version: 1, + nodes: [{ type: "peaking", enabled: true, params: { frequency: 1000, gain: 0, q: 1 } }], + }, + outPath, + { trackId: "t" }, + ); + + const out = readWav(outPath); + expect(out.channels).toBe(2); + // A dropped tail chunk shows up here first. + expect(out.samples.length).toBe(frames * 2); + + // Probe across the whole clip and tightly around the 8 MiB seam. + const seam = (8 * 1024 * 1024) / 4; + const probes = [ + ...Array.from({ length: 60 }, (_, k) => Math.floor((k * (frames - 1)) / 59)), + ...[-2, -1, 0, 1, 2].map((d) => seam + d), + ].filter((i) => i >= 0 && i < frames); + for (const i of probes) { + // Two 16-bit steps of tolerance: the input was quantised on the way in + // and the output again on the way out. + expect(out.samples[i * 2]).toBeCloseTo(at(i), 3); + expect(out.samples[i * 2 + 1]).toBeCloseTo(-at(i), 3); + } + + // The ramp only ever rises, so this reads every sample and fails on a + // chunk reordered, duplicated or over-read anywhere in the file — not just + // at the points probed above. + let breaks = 0; + for (let i = 1; i < frames; i++) { + if ((out.samples[i * 2] ?? 0) < (out.samples[(i - 1) * 2] ?? 0)) breaks += 1; + } + expect(breaks).toBe(0); + }, 180_000); + it("renders a multi-effect chain including reverb", async () => { const input = join(dir, "in.wav"); tone(input); diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index f1da45b093..bca39030b4 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -174,6 +174,35 @@ function interleave(planes: readonly Float32Array[]): Float32Array { return out; } +/** + * Bytes of PCM per CDP message. + * + * The whole track used to cross in a single `page.evaluate` pair — ~184 MB of + * base64 for a 3-minute stereo 48 kHz clip, in one WebSocket frame each way. + * puppeteer-core caps its frames at 256 MB and the browser pool does not use + * the pipe transport, so stereo past ~8.7 minutes failed the render outright; + * V8's max string length is a second wall not far beyond it. Chunking bounds + * both, and bounds the peak Node-side allocation with them: the mixer renders + * tracks concurrently, so every track's payload was live at once. + * + * 8 MiB encodes to ~11 MB of base64. A multiple of 4, so a chunk boundary + * never falls inside a float. + */ +const TRANSFER_BYTES = 8 * 1024 * 1024; + +/** The page-side handover buffers, named off `window` so each step can find them. */ +interface AudioFxPageIo { + in: Uint8Array[][]; + out: Float32Array[]; +} + +interface AudioFxWindow { + __HF_AUDIO_FX?: { + render(p: Float32Array[], r: number, c: string, a?: string): Promise; + }; + __HF_FX_IO?: AudioFxPageIo; +} + /** * Multiply every channel by the envelope, in place, one gain per frame. * @@ -262,66 +291,103 @@ export async function applyAudioFxChain( await page.goto(pathToFileURL(hostPage).href, { waitUntil: "domcontentloaded" }); await page.addScriptTag({ content: getAudioFxRuntimeScript() }); - const rendered = (await page.evaluate( - async ([channelB64, rate, chainJson, automationJson]: [ - string[], - number, - string, - string, - ]) => { - const decode = (b64: string): Float32Array => { - const bin = atob(b64); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return new Float32Array(bytes.buffer); - }; - const api = ( - window as unknown as { - __HF_AUDIO_FX?: { - render( - p: Float32Array[], - r: number, - c: string, - a?: string, - ): Promise; - }; + // Hand the input over a chunk at a time. The chunks stay separate byte + // arrays page-side rather than being concatenated into one string, so + // neither the frame cap nor V8's string limit sees the whole track. + await page.evaluate((count: number) => { + (window as unknown as AudioFxWindow).__HF_FX_IO = { + in: Array.from({ length: count }, (): Uint8Array[] => []), + out: [], + }; + }, planes.length); + + for (let p = 0; p < planes.length; p += 1) { + const plane = planes[p]; + if (!plane) continue; + const bytes = Buffer.from(plane.buffer, plane.byteOffset, plane.length * 4); + for (let at = 0; at < bytes.length; at += TRANSFER_BYTES) { + await page.evaluate( + ([index, b64]: [number, string]) => { + const bin = atob(b64); + const chunk = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) chunk[i] = bin.charCodeAt(i); + (window as unknown as AudioFxWindow).__HF_FX_IO?.in[index]?.push(chunk); + }, + [p, bytes.subarray(at, at + TRANSFER_BYTES).toString("base64")] as [number, string], + ); + } + } + + const outLengths = (await page.evaluate( + async ([rate, chainJson, automationJson]: [number, string, string]) => { + const w = window as unknown as AudioFxWindow; + const io = w.__HF_FX_IO; + if (!w.__HF_AUDIO_FX || !io) throw new Error("audio FX runtime failed to load"); + const inPlanes = io.in.map((chunks) => { + const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const chunk of chunks) { + bytes.set(chunk, at); + at += chunk.length; } - ).__HF_AUDIO_FX; - if (!api) throw new Error("audio FX runtime failed to load"); - const out = await api.render( - channelB64.map(decode), + return new Float32Array(bytes.buffer); + }); + // Dropped before the render allocates its own buffers, so the page + // does not hold two copies of the track at once. + io.in = []; + io.out = await w.__HF_AUDIO_FX.render( + inPlanes, rate, chainJson, automationJson || undefined, ); - const encode = (plane: Float32Array): string => { - const u8 = new Uint8Array(plane.buffer, plane.byteOffset, plane.length * 4); - let s = ""; - const CHUNK = 0x8000; - for (let i = 0; i < u8.length; i += CHUNK) { - s += String.fromCharCode.apply(null, Array.from(u8.subarray(i, i + CHUNK))); - } - return btoa(s); - }; - return out.map(encode); + return io.out.map((plane) => plane.length); }, [ - planes.map((plane) => - Buffer.from(plane.buffer, plane.byteOffset, plane.length * 4).toString("base64"), - ), sampleRate, JSON.stringify(chain), options.automation ? serializeAutomation(options.automation) : "", - ] as [string[], number, string, string], - )) as string[]; + ] as [number, string, string], + )) as number[]; - // byteOffset and byteLength matter: Node pools small allocations, so a - // short payload decodes into an 8 KiB pool and a view over the whole - // ArrayBuffer would read kilobytes of unrelated memory at the wrong length. - const outPlanes = rendered.map((b64) => { - const buf = Buffer.from(b64, "base64"); - return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); - }); + const outPlanes: Float32Array[] = []; + for (let p = 0; p < outLengths.length; p += 1) { + const byteLength = (outLengths[p] ?? 0) * 4; + const parts: Buffer[] = []; + for (let at = 0; at < byteLength; at += TRANSFER_BYTES) { + const b64 = (await page.evaluate( + ([index, offset, limit]: [number, number, number]) => { + const plane = (window as unknown as AudioFxWindow).__HF_FX_IO?.out[index]; + if (!plane) return ""; + const u8 = new Uint8Array( + plane.buffer, + plane.byteOffset + offset, + Math.min(limit, plane.length * 4 - offset), + ); + let s = ""; + const CHUNK = 0x8000; + for (let i = 0; i < u8.length; i += CHUNK) { + // `apply` takes array-likes, so the subarray goes in as it is; + // Array.from boxed every byte of a 32 KiB window for nothing. + s += String.fromCharCode.apply( + null, + u8.subarray(i, i + CHUNK) as unknown as number[], + ); + } + return btoa(s); + }, + [p, at, TRANSFER_BYTES] as [number, number, number], + )) as string; + parts.push(Buffer.from(b64, "base64")); + } + // byteOffset and byteLength matter: Node pools small allocations, so a + // short payload decodes into an 8 KiB pool and a view over the whole + // ArrayBuffer would read kilobytes of unrelated memory at the wrong length. + const buf = Buffer.concat(parts); + outPlanes.push( + new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)), + ); + } if (outPlanes.length === 0 || (outPlanes[0]?.length ?? 0) === 0) { throw new AudioFxRenderError(`Audio FX produced no samples for track ${options.trackId}`); } From b1041a0dc76ae4abc52cb9ff55e2224b4f39b4a2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:55:54 -0700 Subject: [PATCH 14/28] fix(core): reschedule FX automation when the transport changes rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `attachElementFxChain` gets a frozen `{scheduledAt, elapsed, rate}` and commits every lane to absolute context times from it. `setRate` only assigned `playbackRate.value`, so the audio changed speed and the envelopes did not: a lowpass sweeping over 10 clip-seconds, switched to 2x mid-play, eats 20 s of material in 10 s of wall clock while the sweep keeps its original schedule. The runtime's recovery — `stopAll()` plus a reschedule — only runs when `hasBoundedActiveSources()` is true, and a project-level music bed with no `data-duration` is unbounded, so that case never recovered at all. The timing a chain measures from is now a mutable reference frame that `setRate` rebases: `elapsed` is advanced to the playhead the OLD rate carried it to, then the lanes are replayed from there at the new one. That also fixes the second consequence — `timingNow()` was advancing `elapsed` with the stale rate, so every later chain edit re-aimed the envelope at the wrong clip position for as long as the track played. `attachElementFxChain`'s return type is now the named `ElementFxHandle`, so the transport's `ScheduledSource.fx` carries `setRate` rather than a structural `{ dispose(): void }`. Falsified against four mutations: `setRate` a no-op (envelope stays 8 s long), rescheduled without adopting the new rate (6 s instead of 3), rate swapped without rebasing the frame (booked from t=0 instead of t=2), and the guard dropped (rate 0 books a NaN span). Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/runtime/audioFx.test.ts | 118 ++++++++++++++++++ packages/core/src/runtime/audioFx.ts | 43 +++++-- .../src/runtime/webAudioTransport.test.ts | 17 +++ .../core/src/runtime/webAudioTransport.ts | 13 +- 4 files changed, 182 insertions(+), 9 deletions(-) diff --git a/packages/core/src/runtime/audioFx.test.ts b/packages/core/src/runtime/audioFx.test.ts index 9774515810..09374e12e4 100644 --- a/packages/core/src/runtime/audioFx.test.ts +++ b/packages/core/src/runtime/audioFx.test.ts @@ -315,6 +315,124 @@ describe("attachElementFxChain", () => { }); }); + /** + * Lanes are committed to absolute context times, so the schedule is only + * right for the rate it was booked at. Bumping `playbackRate` alone left a + * lowpass sweeping over its original 10 wall-clock seconds while the audio + * underneath ran through 20 clip-seconds of material — and the runtime's + * stopAll()+reschedule recovery never fired for an unbounded source. + */ + describe("a rate change mid-playback", () => { + /** Records what was booked and when, without the browser's overlap rules. */ + class TimedParam { + curves: { time: number; duration: number }[] = []; + ramps: number[] = []; + value = 0; + setValueAtTime(v: number): void { + this.value = v; + } + linearRampToValueAtTime(v: number, t: number): void { + this.ramps.push(t); + this.value = v; + } + setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void { + this.curves.push({ time, duration }); + } + cancelScheduledValues(): void {} + cancelAndHoldAtTime(): void {} + /** The last span booked, however the scheduler chose to express it. */ + last(): { time: number; duration: number } | undefined { + return this.curves.at(-1); + } + } + + const sweep = { + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 300, q: 0.707 } }], + }; + const lane = JSON.stringify({ + version: 1, + lanes: [ + { + target: "fx.n1.frequency", + points: [ + { t: 0, v: 300 }, + { t: 8, v: 3000 }, + ], + }, + ], + }); + + const build = () => { + const clock = { currentTime: 0 }; + const made: { frequency: TimedParam }[] = []; + class TimedNode extends Node { + override frequency = new TimedParam() as unknown as { value: number }; + } + class TimedCtx extends Ctx { + get currentTime(): number { + return clock.currentTime; + } + override createBiquadFilter(): Node { + const n = new TimedNode(); + made.push(n as unknown as { frequency: TimedParam }); + return n; + } + } + const node = document.createElement("audio"); + node.setAttribute("data-fx-chain", JSON.stringify(sweep)); + node.setAttribute("data-automation", lane); + document.body.append(node); + const handle = attachElementFxChain( + new TimedCtx() as unknown as BaseAudioContext, + node, + new Node() as never, + new Node() as never, + { scheduledAt: 0, elapsed: 0, rate: 1 }, + ); + return { clock, node, handle, param: () => made[0]?.frequency as unknown as TimedParam }; + }; + + it("re-aims the envelope so the sweep still ends with the material", () => { + const { clock, handle, param } = build(); + // Booked at 1x: the whole 8 s lane spans 8 s of context time. + expect(param().last()).toEqual({ time: 0, duration: 8 }); + + clock.currentTime = 2; + handle?.setRate(2); + + // 6 clip-seconds are left, and at 2x they take 3 wall-clock seconds. + // Without this the sweep kept its original plan to t=8 while the audio + // ran out at t=5. + expect(param().last()).toEqual({ time: 2, duration: 3 }); + }); + + it("measures later edits from the new rate, not the one it started at", async () => { + // `elapsed` advances at whatever rate the reference frame holds, so a + // frame left at 1x re-aims every subsequent edit at the wrong clip + // position for as long as the track plays. + const { clock, node, handle, param } = build(); + clock.currentTime = 2; + handle?.setRate(2); + + clock.currentTime = 4; + // 2 wall-clock seconds at 2x is 4 clip-seconds, so the playhead is at 6 + // and 2 clip-seconds remain: 1 second of wall clock. + node.setAttribute("data-automation", lane); + await new Promise((r) => setTimeout(r, 0)); + expect(param().last()).toEqual({ time: 4, duration: 1 }); + }); + + it("ignores a rate that is not a rate", () => { + const { clock, handle, param } = build(); + clock.currentTime = 2; + handle?.setRate(0); + handle?.setRate(Number.NaN); + handle?.setRate(1); + expect(param().last()).toEqual({ time: 0, duration: 8 }); + }); + }); + it("tears the chain down on dispose", () => { const src = new Node(); const dst = new Node(); diff --git a/packages/core/src/runtime/audioFx.ts b/packages/core/src/runtime/audioFx.ts index 8bd4c4e3ad..cec709e7a8 100644 --- a/packages/core/src/runtime/audioFx.ts +++ b/packages/core/src/runtime/audioFx.ts @@ -81,15 +81,29 @@ function readChain(el: { getAttribute?(name: string): string | null }): { * first effect is then heard without rescheduling the source. * * With `timing`, the element's automation lanes are scheduled onto the built - * effects as AudioParam ramps, and rescheduled when the attribute is edited. + * effects as AudioParam ramps, and rescheduled when the attribute is edited or + * `setRate` reports the transport changed speed. */ +export interface ElementFxHandle { + dispose(): void; + /** + * Re-aim every booked envelope at a new playback rate. + * + * Lanes are committed to absolute context times, so a param scheduled at 1× + * keeps its original wall-clock plan while the audio underneath runs at the + * new speed: a lowpass sweeping over 10 clip-seconds, switched to 2×, eats + * 20 s of material in 10 s of wall clock with the sweep unchanged. + */ + setRate(rate: number): void; +} + export function attachElementFxChain( ctx: BaseAudioContext, el: { getAttribute?(name: string): string | null }, source: AudioNode, destination: AudioNode, timing?: AutomationTiming, -): { dispose(): void } | null { +): ElementFxHandle | null { const { chain } = readChain(el); // Null means the source runs straight into its gain: an empty chain, or one @@ -166,20 +180,26 @@ export function attachElementFxChain( at && handle ? scheduleChainAutomation(readAutomation(el, next), next, handle.nodes, at) : []; }; + // The reference frame every later reschedule measures from. Mutable because a + // rate change rebases it: `elapsed` has to stop advancing at the old rate the + // instant the new one takes effect, or every subsequent edit re-aims the + // envelope at the wrong clip position. + let frame: AutomationTiming | null = timing ? { ...timing } : null; + attach(chain); - scheduleFor(chain, timing ?? null); + scheduleFor(chain, frame); /** * Re-aim the envelope at the live playhead. An edit lands mid-playback, so * the clip has advanced past the offset the source was scheduled with. */ const timingNow = (): AutomationTiming | null => { - if (!timing) return null; - const now = typeof ctx.currentTime === "number" ? ctx.currentTime : timing.scheduledAt; + if (!frame) return null; + const now = typeof ctx.currentTime === "number" ? ctx.currentTime : frame.scheduledAt; return { scheduledAt: now, - elapsed: timing.elapsed + (now - timing.scheduledAt) * timing.rate, - rate: timing.rate, + elapsed: frame.elapsed + (now - frame.scheduledAt) * frame.rate, + rate: frame.rate, }; }; @@ -239,6 +259,15 @@ export function attachElementFxChain( } return { + setRate: (rate: number) => { + const at = timingNow(); + if (disposed || !at || !Number.isFinite(rate) || rate <= 0 || rate === at.rate) return; + // Rebased at the playhead the OLD rate carried us to, then replayed from + // there at the new one. + frame = { ...at, rate }; + cancelParamLane(automated, at.scheduledAt); + scheduleFor(readChain(el).chain, frame); + }, dispose: () => { disposed = true; observer?.disconnect(); diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index f1597ccc8b..35f935bc5a 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -289,6 +289,23 @@ describe("WebAudioTransport", () => { expect(mock.sourceNode.playbackRate.value).toBe(2); }); + it("setRate re-aims each source's FX automation, not just its playback rate", async () => { + // The lanes are committed to absolute context times when the source is + // scheduled, so bumping playbackRate alone left every automated parameter + // running its original plan over audio moving at a different speed. + const { transport, mock, gen } = setupTransport(100); + await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); + const active = (transport as unknown as { _activeSources: { fx?: unknown }[] }) + ._activeSources; + const setRate = vi.fn(); + active[0]!.fx = { dispose: vi.fn(), setRate }; + + transport.setRate(2); + + expect(setRate).toHaveBeenCalledWith(2); + expect(mock.sourceNode.playbackRate.value).toBe(2); + }); + it("setRate before any sources are scheduled does not throw", () => { const transport = new WebAudioTransport(); expect(() => transport.setRate(2)).not.toThrow(); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 2ce3ea4bba..d77aa7152a 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -1,4 +1,4 @@ -import { attachElementFxChain } from "./audioFx.js"; +import { attachElementFxChain, type ElementFxHandle } from "./audioFx.js"; import type { AutomationTiming } from "../audio/audioFxAutomation.js"; import { swallow } from "./diagnostics"; import { getDebugSurface } from "./globals.js"; @@ -63,7 +63,7 @@ export type ScheduledSource = { sourceNode: AudioBufferSourceNode; gainNode: GainNode; /** FX chain spliced between source and gain, when the element carries one. */ - fx?: { dispose(): void } | null; + fx?: ElementFxHandle | null; compositionStart: number; mediaStart: number; scheduledAt: number; @@ -274,6 +274,14 @@ export class WebAudioTransport { * `getTime()` stays continuous across the change. Sources scheduled to * start in the future keep their original wallclock start time — callers * that need rate-correct future starts should `stopAll()` and reschedule. + * + * Each source's FX automation is re-aimed too. Lanes are committed to + * absolute context times when the source is scheduled, so bumping only + * `playbackRate` left every automated parameter running its original plan + * over audio moving at a different speed. The `stopAll()`+reschedule recovery + * in the runtime is no help here: it only fires for bounded sources, and a + * project-level music bed with no `data-duration` is unbounded, so it never + * recovered at all. */ setRate(rate: number): boolean { const safeRate = normalizeRate(rate); @@ -286,6 +294,7 @@ export class WebAudioTransport { for (const source of this._activeSources) { try { source.sourceNode.playbackRate.value = safeRate; + source.fx?.setRate(safeRate); } catch (err) { swallow("webAudioTransport.setRate", err); } From 8b68ce805667557aa6dc56b1bf03c1745e4025fe Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:01:42 -0700 Subject: [PATCH 15/28] perf(core): reuse the FFT scratch arrays across carve windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both analysis loops allocated a fresh `Float64Array(4096)` pair inside the per-window loop. A 5-minute 48 kHz voiceover is ~7030 windows, so one carve churned ~460 MB of transient Float64Array through the main thread — the panel's thread — for a measurement that needs 64 KiB of scratch. Now one pair is reused; `re` is fully overwritten each window and only `im` has to be cleared. Wall-clock is unchanged (591 ms vs 596 ms on that file, within noise): the FFT dominates and a bump allocation is nearly free. This is a GC-pressure fix, not a latency one, and the band values it produces are bit-identical. The review's third loop is `analyseCarveDuck`, which runs `windowDb` rather than an FFT and allocates nothing per window — nothing to fix there. Striding the Welch hops was tried and REJECTED. It is 27x faster on a 5-minute voiceover (427 ms -> 15 ms), but it moves the result: at strength 0.9 the chosen band set changed (630 Hz became 160 Hz), and it did not converge back to the full read even at 2048 windows. That is the author's carve being silently redrawn to save time on a measurement that already locks the rack while it runs. The reason is recorded in the code so it is not re-attempted. Falsified by deleting `im.fill(0)` from both loops: five tests fail, including the two added here — a steady tone must measure the same bands whatever its length, and its dynamics envelope must sit still rather than sliding as the contamination accumulates. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/audioCarve.test.ts | 42 ++++++++++++++++++++++++++++ packages/core/src/audioCarve.ts | 23 ++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/core/src/audioCarve.test.ts b/packages/core/src/audioCarve.test.ts index 519afc875e..468ee27b4a 100644 --- a/packages/core/src/audioCarve.test.ts +++ b/packages/core/src/audioCarve.test.ts @@ -249,6 +249,48 @@ describe("analyseCarveBands", () => { }); }); +/** + * Both analysis loops reuse one pair of FFT scratch arrays across every window + * rather than allocating a pair per hop — a 5-minute 48 kHz voiceover is ~7000 + * hops, so ~460 MB of transient Float64Array used to churn through the main + * thread for one carve. `re` is fully overwritten each window, but `im` is only + * ever added to, so it has to be cleared; missing that, the imaginary part + * accumulates across windows and every spectrum after the first is wrong by a + * growing amount. + */ +describe("reused FFT scratch across windows", () => { + /** A steady tone on an exact bin centre (48000/4096 x 128), so every window is identical. */ + const steady = (seconds: number): Float32Array => { + const n = Math.floor(SR * seconds); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = 0.5 * Math.sin((2 * Math.PI * 1500 * i) / SR); + return out; + }; + + it("measures the same bands however many windows the clip has", () => { + // Every window carries the same spectrum, so the Welch average cannot + // depend on how many were averaged — unless one window is contaminating + // the next. + const short = analyseCarveBands(steady(0.5), SR, PROFILE); + const long = analyseCarveBands(steady(12), SR, PROFILE); + expect(short.length).toBeGreaterThan(0); + expect(long).toEqual(short); + }); + + it("keeps a steady tone's dynamics envelope flat instead of drifting", () => { + const [lane] = analyseCarveDynamics(steady(12), SR, [{ freq: 1600, gainDb: -8, q: 1.4 }]); + const value = (t: number): number => + sampleAutomationLane({ target: "fx.n1.gain", points: lane!.points }, t); + // Past the attack the cut has to sit still, because the signal does. A + // window contaminated by the one before it grows the measured power over + // the clip, and the envelope — which is relative to the band's own peak — + // slides with it. + expect(value(4)).toBeLessThan(-1); + expect(value(8)).toBeCloseTo(value(4), 0); + expect(value(11)).toBeCloseTo(value(4), 0); + }); +}); + describe("carveBandsToChain", () => { it("turns bands into peaking nodes carrying the analysed values", () => { const chain = carveBandsToChain([{ freq: 1000, gainDb: -6, q: 1.4 }]); diff --git a/packages/core/src/audioCarve.ts b/packages/core/src/audioCarve.ts index 1a0fac18a0..4aff6ee6dd 100644 --- a/packages/core/src/audioCarve.ts +++ b/packages/core/src/audioCarve.ts @@ -313,12 +313,23 @@ function powerSpectrum( const bins = FRAME / 2 + 1; const acc = new Float64Array(bins); + // Reused across hops. These used to be allocated inside the loop: a 5-minute + // 48 kHz voiceover is ~7000 hops, so ~460 MB of transient Float64Array + // churned through the main thread for a single carve. `re` is fully + // overwritten below; only `im` has to be cleared. + const re = new Float64Array(FRAME); + const im = new Float64Array(FRAME); + // + // Every hop is still read. Striding them — Welch's average is supposed to + // settle long before 7000 windows — was measured on a 5-minute voiceover and + // moves the result: at strength 0.9 the chosen band set changed (630 Hz for + // 160 Hz), and it did not converge back to the full read even at 2048 + // windows. 27x faster is not worth silently redrawing the author's carve. let frames = 0; for (let start = 0; start + FRAME <= n; start += HOP) { // Goertzel-free naive DFT would be O(n^2); use a real FFT via recursion on // a copied frame. FRAME is a power of two so the radix-2 split is exact. - const re = new Float64Array(FRAME); - const im = new Float64Array(FRAME); + im.fill(0); for (let i = 0; i < FRAME; i++) re[i] = (padded[start + i] ?? 0) * window[i]!; fft(re, im); for (let k = 0; k < bins; k++) acc[k]! += re[k]! * re[k]! + im[k]! * im[k]!; @@ -554,9 +565,13 @@ export function analyseCarveDynamics( const times: number[] = []; const perBand = bands.map(() => [] as number[]); + // Reused across windows, as in powerSpectrum. `re` is fully overwritten + // below; only `im` has to be cleared. The hop here is already bounded by + // POINT_BUDGET, so there is nothing to stride. + const re = new Float64Array(FRAME); + const im = new Float64Array(FRAME); for (let start = 0; start < voice.length; start += hop) { - const re = new Float64Array(FRAME); - const im = new Float64Array(FRAME); + im.fill(0); for (let i = 0; i < FRAME; i++) re[i] = (voice[start + i] ?? 0) * window[i]!; fft(re, im); const power: number[] = []; From 674927ac3a7f00a8d1bf45a9759505a46aac5a41 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:08:56 -0700 Subject: [PATCH 16/28] fix(core): let a failed worklet registration be retried `ensureAudioFxWorklets` cached its promise per context and never removed a rejected one, while `readyContexts` was only written on success. So callers correctly kept asking and every ask replayed the same rejection: one transient `addModule` failure left the limiter, compressor, gate and bitcrush silent for the life of that AudioContext, with no path back short of a page reload. The entry is now dropped on failure so the next attempt actually retries. Falsified by removing the `registered.delete(ctx)`: the retry replays the rejection instead of resolving. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/src/audio/audioFxWorklets.test.ts | 42 +++++++++++++++++++ packages/core/src/audio/audioFxWorklets.ts | 15 ++++++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/audio/audioFxWorklets.test.ts diff --git a/packages/core/src/audio/audioFxWorklets.test.ts b/packages/core/src/audio/audioFxWorklets.test.ts new file mode 100644 index 0000000000..87a396c8b3 --- /dev/null +++ b/packages/core/src/audio/audioFxWorklets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js"; + +/** Just enough of a BaseAudioContext for the registration cache to key on. */ +const contextWith = (addModule: (url: string) => Promise): BaseAudioContext => + ({ audioWorklet: { addModule } }) as unknown as BaseAudioContext; + +describe("ensureAudioFxWorklets", () => { + it("registers once per context and reuses the result", async () => { + const addModule = vi.fn(async () => undefined); + const ctx = contextWith(addModule); + + await ensureAudioFxWorklets(ctx); + await ensureAudioFxWorklets(ctx); + + expect(addModule).toHaveBeenCalledTimes(1); + expect(audioFxWorkletsReady(ctx)).toBe(true); + }); + + it("retries after a failure instead of replaying it forever", async () => { + // The rejected promise used to stay in the cache, so every later attempt + // got the same rejection back — the limiter, compressor, gate and bitcrush + // were silent for the life of the context after one transient failure. + const addModule = vi + .fn<(url: string) => Promise>() + .mockRejectedValueOnce(new Error("module load failed")) + .mockResolvedValue(undefined); + const ctx = contextWith(addModule); + + await expect(ensureAudioFxWorklets(ctx)).rejects.toThrow("module load failed"); + expect(audioFxWorkletsReady(ctx)).toBe(false); + + await expect(ensureAudioFxWorklets(ctx)).resolves.toBeUndefined(); + expect(addModule).toHaveBeenCalledTimes(2); + expect(audioFxWorkletsReady(ctx)).toBe(true); + }); + + it("refuses a context with no AudioWorklet rather than hanging", async () => { + const ctx = {} as BaseAudioContext; + await expect(ensureAudioFxWorklets(ctx)).rejects.toThrow(/secure context/); + }); +}); diff --git a/packages/core/src/audio/audioFxWorklets.ts b/packages/core/src/audio/audioFxWorklets.ts index e421d34099..2dd5d3b258 100644 --- a/packages/core/src/audio/audioFxWorklets.ts +++ b/packages/core/src/audio/audioFxWorklets.ts @@ -183,7 +183,9 @@ class HfBitcrush extends AudioWorkletProcessor { this.p = o.processorOptions || {}; this.holds = []; this.held = []; - this.port.onmessage = (e) => { this.p = { ...this.p, ...e.data }; }; + this.port.onmessage = (e) => { + this.p = { ...this.p, ...e.data }; + }; } process(inputs, outputs) { const i = inputs[0], o = outputs[0]; @@ -243,7 +245,16 @@ export function ensureAudioFxWorklets(ctx: BaseAudioContext): Promise { )}`; await ctx.audioWorklet.addModule(url); readyContexts.add(ctx); - })(); + })().catch((err: unknown) => { + // A failed registration must not be remembered. `readyContexts` is only + // written on success, so callers correctly keep asking — and every ask + // replayed this same rejected promise, leaving the limiter, compressor, + // gate and bitcrush silent for the life of the context with no way back. + // One transient failure (a slow module load, a context still warming up) + // permanently disabled half the rack. + registered.delete(ctx); + throw err; + }); registered.set(ctx, modulePromise); } return modulePromise; From 644633c0929866b76f9afc832cf6f548f45a3c34 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:11:11 -0700 Subject: [PATCH 17/28] fix(core): retire a worklet processor on dispose instead of only disconnecting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AudioWorkletProcessor lives until its `process()` returns false; all four returned true unconditionally, and `dispose` only called `node.disconnect()`. So every chain rebuild that dropped a limiter, compressor, gate or bitcrush left it running on the audio thread for the rest of the session — and a structural edit rebuilds the whole graph, so a few passes over a carved bed stacked up abandoned processors that nothing could reach to stop. `dispose` now posts `__hfDispose`, which each processor treats as its cue to return false from then on. A later parameter update cannot revive it. Falsified two ways: dropping the `if (this.dead) return false;` guards (each processor keeps running after dispose) and dropping the postMessage (dispose sends nothing). The processor test evaluates the module source pulled back out of the data: URL that registration hands to `addModule`, so it exercises what actually ships rather than a copy. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/audio/audioFxGraph.test.ts | 10 +++ packages/core/src/audio/audioFxGraph.ts | 12 +++- .../core/src/audio/audioFxWorklets.test.ts | 61 +++++++++++++++++++ packages/core/src/audio/audioFxWorklets.ts | 8 +++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/core/src/audio/audioFxGraph.test.ts b/packages/core/src/audio/audioFxGraph.test.ts index 1a8d235196..316deff7a1 100644 --- a/packages/core/src/audio/audioFxGraph.test.ts +++ b/packages/core/src/audio/audioFxGraph.test.ts @@ -155,6 +155,16 @@ describe("buildFxNode", () => { expect(workletNodes[0]!.messages[0]).toMatchObject({ threshold: -30 }); }); + it("tells a worklet processor to retire on dispose, not just disconnect it", () => { + // Disconnecting leaves the processor alive — it lives until `process()` + // returns false — so every rebuild that dropped a worklet effect left one + // running on the audio thread for the rest of the session. + workletNodes.length = 0; + const h = buildFxNode(asCtx(ctx()), "compressor", defaultAudioFxParams("compressor")); + h.dispose(); + expect(workletNodes[0]!.messages).toEqual([{ __hfDispose: true }]); + }); + it("rebuilds the saturation curve for the selected shape", () => { const c = ctx(); buildFxNode(asCtx(c), "saturate", { ...defaultAudioFxParams("saturate"), type: "hard" }); diff --git a/packages/core/src/audio/audioFxGraph.ts b/packages/core/src/audio/audioFxGraph.ts index 09f3c91a7a..027064ebe4 100644 --- a/packages/core/src/audio/audioFxGraph.ts +++ b/packages/core/src/audio/audioFxGraph.ts @@ -176,7 +176,17 @@ function workletBuilder(processor: string): Builder { input: node, output: node, update: (v) => node.port.postMessage({ ...v }), - dispose: () => node.disconnect(), + dispose: () => { + // Disconnecting is not enough to retire an AudioWorkletProcessor: it + // lives until its `process()` returns false, and these all returned + // true unconditionally. So every chain rebuild that dropped a limiter, + // compressor, gate or bitcrush left it running on the audio thread for + // the rest of the session, and a few edits to a carved bed accumulated + // a stack of them. The processors treat this message as their cue to + // stop. + node.port.postMessage({ __hfDispose: true }); + node.disconnect(); + }, }; }; } diff --git a/packages/core/src/audio/audioFxWorklets.test.ts b/packages/core/src/audio/audioFxWorklets.test.ts index 87a396c8b3..1e9e06ca66 100644 --- a/packages/core/src/audio/audioFxWorklets.test.ts +++ b/packages/core/src/audio/audioFxWorklets.test.ts @@ -40,3 +40,64 @@ describe("ensureAudioFxWorklets", () => { await expect(ensureAudioFxWorklets(ctx)).rejects.toThrow(/secure context/); }); }); + +/** + * A processor lives until its `process()` returns false — disconnecting the + * node does not retire it. These all returned true unconditionally, so every + * chain rebuild that dropped a worklet effect left it running on the audio + * thread for the rest of the session. + * + * The source is taken from the data: URL registration actually hands to + * `addModule`, so this also proves the URL carries what it claims to. + */ +describe("the worklet processors themselves", () => { + /** Evaluate the registered module and hand back the processor classes by name. */ + async function loadProcessors(): Promise Processor>> { + let moduleSource = ""; + await ensureAudioFxWorklets( + contextWith(async (url: string) => { + moduleSource = atob(url.replace("data:text/javascript;base64,", "")); + }), + ); + const made = new Map Processor>(); + class Base { + port = { + onmessage: null as ((e: { data: unknown }) => void) | null, + postMessage: (data: unknown) => this.port.onmessage?.({ data }), + }; + } + new Function("AudioWorkletProcessor", "registerProcessor", "sampleRate", moduleSource)( + Base, + (name: string, cls: new (o: unknown) => Processor) => made.set(name, cls), + 48000, + ); + return made; + } + + interface Processor { + port: { postMessage(data: unknown): void }; + process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean; + } + + const block = (): Float32Array[][] => [[new Float32Array(128)]]; + + it("every processor keeps running until it is told to stop, then retires", async () => { + const processors = await loadProcessors(); + expect([...processors.keys()]).toEqual([ + "hf-compressor", + "hf-limiter", + "hf-gate", + "hf-bitcrush", + ]); + + for (const [name, Cls] of processors) { + const p = new Cls({ processorOptions: {} }); + expect(p.process(block(), block()), `${name} retired before it was disposed`).toBe(true); + p.port.postMessage({ __hfDispose: true }); + expect(p.process(block(), block()), `${name} kept running after dispose`).toBe(false); + // And it stays retired — a later parameter update must not revive it. + p.port.postMessage({ mix: 0.5 }); + expect(p.process(block(), block()), `${name} came back to life`).toBe(false); + } + }); +}); diff --git a/packages/core/src/audio/audioFxWorklets.ts b/packages/core/src/audio/audioFxWorklets.ts index 2dd5d3b258..f2261ea8a1 100644 --- a/packages/core/src/audio/audioFxWorklets.ts +++ b/packages/core/src/audio/audioFxWorklets.ts @@ -60,11 +60,13 @@ class HfCompressor extends AudioWorkletProcessor { this.p = o.processorOptions || {}; this.env = new EnvBank(this.p.attack ?? 20, this.p.release ?? 250); this.port.onmessage = (e) => { + if (e.data && e.data.__hfDispose) { this.dead = true; return; } this.p = { ...this.p, ...e.data }; this.env.set(this.p.attack ?? 20, this.p.release ?? 250); }; } process(inputs, outputs) { + if (this.dead) return false; const i = inputs[0], o = outputs[0]; if (!i || !i.length) return true; const p = this.p; @@ -105,11 +107,13 @@ class HfLimiter extends AudioWorkletProcessor { this.p = o.processorOptions || {}; this.env = new EnvBank(this.p.attack ?? 5, this.p.release ?? 50); this.port.onmessage = (e) => { + if (e.data && e.data.__hfDispose) { this.dead = true; return; } this.p = { ...this.p, ...e.data }; this.env.set(this.p.attack ?? 5, this.p.release ?? 50); }; } process(inputs, outputs) { + if (this.dead) return false; const i = inputs[0], o = outputs[0]; if (!i || !i.length) return true; const ceiling = dbToLin(this.p.limit ?? -1); @@ -136,11 +140,13 @@ class HfGate extends AudioWorkletProcessor { this.env = new EnvBank(this.p.attack ?? 1, this.p.release ?? 100); this.gains = []; this.port.onmessage = (e) => { + if (e.data && e.data.__hfDispose) { this.dead = true; return; } this.p = { ...this.p, ...e.data }; this.env.set(this.p.attack ?? 1, this.p.release ?? 100); }; } process(inputs, outputs) { + if (this.dead) return false; const i = inputs[0], o = outputs[0]; if (!i || !i.length) return true; const p = this.p; @@ -184,10 +190,12 @@ class HfBitcrush extends AudioWorkletProcessor { this.holds = []; this.held = []; this.port.onmessage = (e) => { + if (e.data && e.data.__hfDispose) { this.dead = true; return; } this.p = { ...this.p, ...e.data }; }; } process(inputs, outputs) { + if (this.dead) return false; const i = inputs[0], o = outputs[0]; if (!i || !i.length) return true; const p = this.p; From 267161cf55b7621a52978956cffa7d4438514d9a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:13:25 -0700 Subject: [PATCH 18/28] fix(core): stop reading a blank FX parameter as zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeAudioFxParams` coerced with a bare `Number(raw)`, and `Number(null)`, `Number("")`, `Number(false)` and `Number([])` are all 0 — every one of them finite. So a parameter that arrived missing or blanked clamped to 0 instead of falling back to the effect's declared default, and because 0 is a legal setting for most of these knobs nothing downstream could tell the difference: a compressor whose threshold came through as null sat at 0 dB and never engaged. Now only a number, or a string that actually spells one, counts — the same rule `numberOrNull` in audioAutomation.ts already applies. Panel inputs arrive as strings, so those still work. Falsified by restoring the bare `Number(raw)`: null reads as 0 dB instead of -24. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/audioFx.test.ts | 21 +++++++++++++++++++++ packages/core/src/audioFx.ts | 15 ++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/core/src/audioFx.test.ts b/packages/core/src/audioFx.test.ts index e59819b952..3caaa2464c 100644 --- a/packages/core/src/audioFx.test.ts +++ b/packages/core/src/audioFx.test.ts @@ -69,6 +69,27 @@ describe("normalizeAudioFxParams", () => { expect(v.gain).toBe(0); }); + it("treats a blank or missing value as absent rather than as zero", () => { + // `Number(null)`, `Number("")`, `Number(false)` and `Number([])` are all 0 + // and all finite, so these used to clamp to 0 instead of falling back. Zero + // is a legal setting for most of these knobs, so nothing downstream could + // tell: a compressor whose threshold arrived as null sat at 0 dB and never + // engaged, silently, rather than at its declared -24 dB. + const def = defaultAudioFxParams("compressor").threshold; + expect(def).not.toBe(0); + for (const blank of [null, undefined, "", " ", false, [], {}]) { + expect( + normalizeAudioFxParams("compressor", { threshold: blank as unknown as number }).threshold, + `${JSON.stringify(blank)} was read as a number`, + ).toBe(def); + } + // A string that really does spell a number still counts — that is how the + // panel's inputs arrive. + expect( + normalizeAudioFxParams("compressor", { threshold: "-30" as unknown as number }).threshold, + ).toBe(-30); + }); + it("falls back to the default for an unrecognised enum value", () => { expect(normalizeAudioFxParams("saturate", { type: "sawtooth" }).type).toBe("tanh"); expect(normalizeAudioFxParams("saturate", { type: "atan" }).type).toBe("atan"); diff --git a/packages/core/src/audioFx.ts b/packages/core/src/audioFx.ts index 1e38c4f298..32be62bc64 100644 --- a/packages/core/src/audioFx.ts +++ b/packages/core/src/audioFx.ts @@ -761,7 +761,20 @@ export function normalizeAudioFxParams( out[p.key] = ok ? (raw as string) : p.default; continue; } - const n = typeof raw === "number" ? raw : Number(raw); + // Only a number, or a string that actually spells one. `Number(null)`, + // `Number("")`, `Number(false)` and `Number([])` are all 0 and all pass + // Number.isFinite, so a missing or blanked value used to clamp to 0 rather + // than fall back to the declared default — and 0 is a legal value for most + // of these knobs, so nothing downstream could tell. A compressor whose + // threshold arrived as null sat at 0 dB and never engaged, silently, + // instead of at its -24 dB default. `numberOrNull` in audioAutomation.ts + // already guards exactly this. + const n = + typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim() !== "" + ? Number(raw) + : Number.NaN; out[p.key] = Number.isFinite(n) ? Math.min(p.max, Math.max(p.min, n)) : p.default; } return out; From 222390d6ef13be4bc33119719842945204e399fe Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:16:07 -0700 Subject: [PATCH 19/28] fix(producer): classify the audio failure that takes the whole mix down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runAudioStage` catches the rejection `processCompositionAudio` throws when an FX failure cannot be degraded past — and returned `audioFailures: undefined` with it. `applyDistributedAudioWarningPolicy` reads owner, retryability, reason and stage off that list, so the FATAL failure was reported with an empty reasons array, an empty stages array and no owner: strictly less classification than a single dropped track gets, which is the opposite of what the stage's own comment says it exists to do. A failure is now synthesised from the error. "internal" is the honest bucket — the stage names enumerate ffmpeg steps and this is the FX render, which is none of them — and system/non-retryable is right for a browser or chain that will not build. The detail is bounded at 2000 characters like the mixer's own. Falsified by restoring `audioFailures: undefined`: both assertions fail. Producer's suite has 50 pre-existing collect failures under vitest (files that import `bun:test`); passing goes 533 -> 534, unchanged otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/render/stages/audioStage.test.ts | 22 ++++++++++++++++++- .../src/services/render/stages/audioStage.ts | 20 +++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/producer/src/services/render/stages/audioStage.test.ts b/packages/producer/src/services/render/stages/audioStage.test.ts index 62dc0ba1b9..b0b145a5c6 100644 --- a/packages/producer/src/services/render/stages/audioStage.test.ts +++ b/packages/producer/src/services/render/stages/audioStage.test.ts @@ -122,7 +122,27 @@ describe("runAudioStage", () => { const result = await runAudioStage(makeInput()); expect(result.hasAudio).toBe(false); expect(result.audioError).toMatch(/Audio FX failed for track bgm/); - expect(result.audioFailures).toBeUndefined(); + // And it is classified. This used to come back undefined, so the warning + // policy — which reads owner, retryability, reason and stage off this list + // — described the FATAL failure with strictly less detail than a single + // dropped track gets. + expect(result.audioFailures).toEqual([ + { + stage: "internal", + reason: "internal", + owner: "system", + retryable: false, + detail: "Audio FX failed for track bgm: browser launch failed", + }, + ]); + }); + + it("bounds the synthesised failure's detail", async () => { + // `detail` is contractually bounded diagnostic text; an ffmpeg-flavoured + // message can run to tens of kilobytes. + processCompositionAudioMock.mockRejectedValue(new Error("x".repeat(5_000))); + const result = await runAudioStage(makeInput()); + expect(result.audioFailures?.[0]?.detail.length).toBe(2_000); }); it("lets an abort keep its own shape rather than becoming an audio error", async () => { diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts index bac3d3f552..7dd17cd133 100644 --- a/packages/producer/src/services/render/stages/audioStage.ts +++ b/packages/producer/src/services/render/stages/audioStage.ts @@ -84,12 +84,28 @@ export async function runAudioStage(input: AudioStageInput): Promise Date: Sat, 8 Aug 2026 12:18:31 -0700 Subject: [PATCH 20/28] chore: drop a dead audio-FX ignoreExports entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both symbols the entry named are gone: `AUDIO_FX_WORKLET_SOURCE` is a module-private const rather than an export, and `__resetAudioFxWorkletsForTests` does not exist at all. The entry has been inert since c3291f291 added it, and an ignore that names nothing only makes the next reader look for something that is not there. Left alone deliberately: the file-scoped `health.ignore` on `packages/core/src/runtime/media.ts`. It is over-broad — its comment says it is for `refreshRuntimeMediaCache`, but it also exempts `syncRuntimeMedia`, which IS what this stack changed. `health.ignore` takes file paths only, so there is no way to narrow it in config; removing it wants a fallow run to confirm what it then reports, and fallow already fails on this stack. Co-Authored-By: Claude Opus 5 (1M context) --- .fallowrc.jsonc | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 3a9511683b..e27b71cfa4 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -149,13 +149,6 @@ "file": "packages/studio/src/utils/studioHelpers.ts", "exports": ["resolveDroppedAssetDimensions"], }, - // Audio FX worklets sit near the bottom of the audio stack: the worklet - // source and its test reset are consumed by the runtime and engine PRs - // upstack, so a per-PR audit against the merge base sees them as unused. - { - "file": "packages/core/src/audio/audioFxWorklets.ts", - "exports": ["AUDIO_FX_WORKLET_SOURCE", "__resetAudioFxWorkletsForTests"], - }, { "file": "packages/core/src/audio/audioFxGraph.ts", "exports": ["ensureAudioFxWorklets"], From cfc993ca85eedf30a3c6c5844c8cfd19ae1265c6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:23:28 -0700 Subject: [PATCH 21/28] fix(studio): key FX rows by node id so a mid-edit stays with its effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rack's rows were keyed `${type}-${index}`. Two effects of the SAME type keep those keys through a reorder — position 0 is `peaking-0` before and after — so React reused each row where it stood rather than moving it with its node. The controls hold real state (a number field mid-edit is held as text, a drag holds a local value), and that state stayed at the position: moving an effect handed its half-typed value to whichever effect took its slot. Different types happened to be safe, because the type is in the key. Same types were not, and a rack with two EQ bands is the common case. Now keyed by `node.id`, which the carve module's own list above already does, falling back to the old form for a node minted without one. Falsified by restoring the index key: the slot keeps showing 123 after the effect carrying it moved away. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/propertyPanelFxSection.test.tsx | 49 +++++++++++++++++++ .../editor/propertyPanelFxSection.tsx | 8 ++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 345d3574d9..c6a35307f7 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -163,6 +163,55 @@ describe("FxSection chain", () => { expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "peaking"]); }); + it("keeps a half-typed value with its own effect across a reorder", () => { + // Rows used to be keyed `${type}-${index}`, so two effects of the same type + // kept their keys through a reorder and React reused each row where it + // stood. The controls hold real state — a number field mid-edit is held as + // text — so the buffer stayed at the position and landed on whichever + // effect moved into it. + const peaking = (id: string, frequency: number) => ({ + type: "peaking", + id, + enabled: true, + params: { ...defaultAudioFxParams("peaking"), frequency }, + }); + const a = peaking("pa", 400); + const b = peaking("pb", 1600); + const chainOfNodes = (...nodes: unknown[]): HfAudioFxChain => + ({ version: 1, nodes }) as HfAudioFxChain; + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (chain: HfAudioFxChain) => + act(() => { + root.render( + , + ); + }); + + render(chainOfNodes(a, b)); + // Only the first card is open, which is the one being edited. + const openFrequency = (): HTMLInputElement => + host.querySelector(".hf-fx-node .hf-fx-number")!; + + expect(openFrequency().value).toBe("400"); + typeInto(openFrequency(), "123"); + expect(openFrequency().value).toBe("123"); + + // The author moves that effect down; the other one takes the open slot. + render(chainOfNodes(b, a)); + + expect(openFrequency().value).toBe("1600"); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index ab696119af..33fda26891 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -795,7 +795,13 @@ export function FxSection({ handBuilt.map(({ node, i }) => { return ( Date: Sat, 8 Aug 2026 12:48:43 -0700 Subject: [PATCH 22/28] refactor: drop the non-null assertion and two casts the guards already cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three violations of the repo's "avoid `any`/`as T`" rule and the standing no-`!` rule, all in the carve's analysis path. `resolveAutomationRange` cast to `HfAudioFxNumberParam` immediately after `if (!param || param.kind !== "number") return null` — the discriminated union was already narrowed, so the cast asserted what the compiler knew. Removing it leaves tsc clean, which is the proof it was dead. `analyse` read `doc!.baseURI` and `el.getAttribute("src")!`, and laundered `getElementById`'s `HTMLElement | null` through `as HTMLAudioElement | null` into a predicate that only checked for a `src` attribute — so the "is this an audio element" claim was never actually tested by anything. The document is now guarded once at the top, and the voices are read out to `{ src, start }` values as they are found, which makes both fields non-null by construction rather than by assertion. The element check is by `tagName`, not `instanceof HTMLAudioElement`: these elements belong to the composition's iframe document, so this realm's constructor never matches them. It is not a narrowing either — `sourceOptions` is built from `doc.querySelectorAll("audio[id]")`, so a carve source is an `