/* Copyright 2026 Lotus Guild SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type AudioProcessorOptions } from "livekit-client"; import { assertModuleExport, type LotusDenoiseConfig, LotusDenoiseProcessor, prepareDenoiseAssets, releasePreparedDenoiseAssets, } from "./lotusDenoiseProcessor"; const baseConfig: LotusDenoiseConfig = { model: "rnnoise", assetBase: "https://example.invalid/denoise/", gate: false, gateThreshold: -45, floor: 0.15, }; function makeProcessor( overrides: Partial = {}, ): LotusDenoiseProcessor { return new LotusDenoiseProcessor({ ...baseConfig, ...overrides }); } /** * Minimal fake AudioContext: tracks state, records suspend/resume, and lets a * test fire `statechange` like the browser would after an OS interruption. */ class FakeAudioContext extends EventTarget { public static created: FakeAudioContext[] = []; public state: AudioContextState = "running"; public readonly sampleRate: number; public readonly audioWorklet = { addModule: vi.fn().mockResolvedValue(undefined), }; public suspend = vi.fn(async (): Promise => { await Promise.resolve(); this.state = "suspended"; }); public resume = vi.fn(async (): Promise => { await Promise.resolve(); this.state = "running"; }); public close = vi.fn(async (): Promise => { await Promise.resolve(); this.state = "closed"; }); public constructor(opts?: { sampleRate?: number }) { super(); this.sampleRate = opts?.sampleRate ?? 48_000; FakeAudioContext.created.push(this); } /** Simulate an external (OS/browser) suspension. */ public externallySuspend(): void { this.state = "suspended"; this.dispatchEvent(new Event("statechange")); } } beforeEach(() => { FakeAudioContext.created = []; vi.stubGlobal("AudioContext", FakeAudioContext); // The flat wasm prefetch just warms a cache; give it a 200 so prepare() // succeeds without a network. vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, arrayBuffer: (): ArrayBuffer => new ArrayBuffer(8), }), ); }); afterEach(async () => { await releasePreparedDenoiseAssets(); vi.unstubAllGlobals(); }); /** Skip the real graph (needs worklets) — init() only needs a track back. */ function stubGraph(processor: LotusDenoiseProcessor): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (processor as any).buildGraph = async (): Promise => { await Promise.resolve(); return { source: { disconnect: vi.fn() }, nodes: [], disposes: [], track: { stop: vi.fn() }, }; }; } describe("LotusDenoiseProcessor.restart", () => { test("falls back to processedTrack = undefined (never the raw LiveKit track) when graph rebuild fails", async () => { const processor = makeProcessor(); // Stub out the AudioContext/graph plumbing: pretend the context is fine // but the graph rebuild (wasm load / worklet construction) throws, which // is the path this test targets. // eslint-disable-next-line @typescript-eslint/no-explicit-any (processor as any).ensureContext = async (): Promise => { await Promise.resolve(); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (processor as any).buildGraph = async (): Promise => { await Promise.resolve(); throw new Error("simulated graph build failure"); }; const rawTrack = { stop: (): void => undefined, } as unknown as MediaStreamTrack; await processor.restart({ track: rawTrack, } as unknown as AudioProcessorOptions); // Must NOT be the raw, LiveKit-owned track: LiveKit's // internalStopProcessor() calls `processor.processedTrack?.stop()` then // re-publishes the same `_mediaStreamTrack` object, which would kill the // live mic on the next stopProcessor()/teardown if we handed it back here. expect(processor.processedTrack).toBeUndefined(); expect(processor.processedTrack).not.toBe(rawTrack); }); }); describe("prepareDenoiseAssets (#7)", () => { test("init() adopts the context/worklet prepared before setProcessor instead of loading inline", async () => { await prepareDenoiseAssets(baseConfig); // The heavy parts ran up front: one context, worklet module added, wasm fetched. expect(FakeAudioContext.created).toHaveLength(1); const prepared = FakeAudioContext.created[0]; expect(prepared.audioWorklet.addModule).toHaveBeenCalledWith( `${baseConfig.assetBase}rnnoiseWorklet.js`, ); expect(fetch).toHaveBeenCalled(); const processor = makeProcessor(); stubGraph(processor); await processor.init({ track: {} as MediaStreamTrack, } as unknown as AudioProcessorOptions); // No second AudioContext / addModule inside init (i.e. under the lock). expect(FakeAudioContext.created).toHaveLength(1); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((processor as any).ctx).toBe(prepared); // The claim is one-shot: a later processor prepares its own. const second = makeProcessor(); stubGraph(second); await second.init({ track: {} as MediaStreamTrack, } as unknown as AudioProcessorOptions); expect(FakeAudioContext.created).toHaveLength(2); await processor.destroy(); await second.destroy(); }); test("releasePreparedDenoiseAssets closes an unclaimed prepared context", async () => { await prepareDenoiseAssets(baseConfig); const prepared = FakeAudioContext.created[0]; await releasePreparedDenoiseAssets(); expect(prepared.close).toHaveBeenCalled(); }); }); describe("LotusDenoiseProcessor.setMicMuted (#9)", () => { test("suspends the owned context on mute, resumes on unmute, and the statechange watcher leaves the mute suspension alone", async () => { const processor = makeProcessor(); stubGraph(processor); await processor.init({ track: {} as MediaStreamTrack, } as unknown as AudioProcessorOptions); const ctx = FakeAudioContext.created[0]; expect(ctx.state).toBe("running"); processor.setMicMuted(true); await Promise.resolve(); expect(ctx.suspend).toHaveBeenCalledTimes(1); expect(ctx.state).toBe("suspended"); // The browser fires statechange for our own suspend too; the watcher must // NOT undo it while muted. ctx.resume.mockClear(); ctx.dispatchEvent(new Event("statechange")); await Promise.resolve(); expect(ctx.resume).not.toHaveBeenCalled(); expect(ctx.state).toBe("suspended"); processor.setMicMuted(false); await Promise.resolve(); expect(ctx.resume).toHaveBeenCalledTimes(1); expect(ctx.state).toBe("running"); // An EXTERNAL suspension while unmuted is still healed by the watcher. ctx.resume.mockClear(); ctx.externallySuspend(); await Promise.resolve(); expect(ctx.resume).toHaveBeenCalledTimes(1); await processor.destroy(); }); test("a processor attached while already muted starts suspended", async () => { const processor = makeProcessor(); stubGraph(processor); processor.setMicMuted(true); await processor.init({ track: {} as MediaStreamTrack, } as unknown as AudioProcessorOptions); const ctx = FakeAudioContext.created[0]; expect(ctx.state).toBe("suspended"); await processor.destroy(); }); }); describe("assertModuleExport (#26)", () => { test("returns the module when the export is a function", () => { const mod = { createNoiseSuppressionAudioWorklet: (): void => undefined }; expect( assertModuleExport(mod, "createNoiseSuppressionAudioWorklet", "x.js"), ).toBe(mod); }); test("throws a clear error naming the module and export when missing", () => { expect(() => assertModuleExport( { other: 1 }, "DeepFilterNet3Core", "dfn/index.esm.js", ), ).toThrow(/dfn\/index\.esm\.js does not export DeepFilterNet3Core/); expect(() => assertModuleExport(undefined, "X", "y.js")).toThrow( /does not export X/, ); }); });