fix(core): retire a worklet processor on dispose instead of only disconnecting it

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:11:11 -07:00
co-authored by Claude Opus 5
parent 85257571c8
commit eb60133e33
4 changed files with 90 additions and 1 deletions
@@ -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" });
+11 -1
View File
@@ -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();
},
};
};
}
@@ -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<Map<string, new (o: unknown) => Processor>> {
let moduleSource = "";
await ensureAudioFxWorklets(
contextWith(async (url: string) => {
moduleSource = atob(url.replace("data:text/javascript;base64,", ""));
}),
);
const made = new Map<string, new (o: unknown) => 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);
}
});
});
@@ -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;