- Assets (context, worklets, wasm, DFN core) are prepared as soon as the flag is seen, so init() under LiveKit's trackChangeLock only wires already-loaded pieces; resume timeout 3 s -> 500 ms (#7). - init failure retries once with rnnoise; success/failure is reported to the host as io.lotus.denoise_state so the UI can reflect reality (#8). - Mic TrackMuted/TrackUnmuted suspend/resume the processor's context so no inference runs on silence (#9). - Every node is explicit mono; the dry path gets a per-model DelayNode so the floor mix no longer comb-filters (#24, #25). - DTLN/DFN dynamic imports are typed and their exports asserted at load, feeding the #8 fallback instead of failing silently (#26). Unit-tested (13 tests across the two files). Fixes #7 Fixes #8 Fixes #9 Fixes #24 Fixes #25 Fixes #26 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
/*
|
|
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<LotusDenoiseConfig> = {},
|
|
): 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<void> => {
|
|
await Promise.resolve();
|
|
this.state = "suspended";
|
|
});
|
|
public resume = vi.fn(async (): Promise<void> => {
|
|
await Promise.resolve();
|
|
this.state = "running";
|
|
});
|
|
public close = vi.fn(async (): Promise<void> => {
|
|
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<unknown> => {
|
|
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<void> => {
|
|
await Promise.resolve();
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(processor as any).buildGraph = async (): Promise<never> => {
|
|
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/,
|
|
);
|
|
});
|
|
});
|