From eb60133e336edaa9cc45380cf4839a35befa5535 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:11:11 -0700 Subject: [PATCH] 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;