mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 21:29:34 +00:00
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:
co-authored by
Claude Opus 5
parent
85257571c8
commit
eb60133e33
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user