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;