fix(lotus): denoise — prefetch assets, RNNoise fallback + host notify, suspend while muted, mono/delay graph, typed modules

- 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
This commit is contained in:
Lotus CI
2026-09-13 01:22:20 -04:00
co-authored by Claude Opus 5
parent 872b877248
commit e504a31efd
4 changed files with 740 additions and 87 deletions
+138 -5
View File
@@ -10,21 +10,40 @@ import { ParticipantEvent, Track } from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { startLotusDenoise } from "./lotusDenoise";
import { LotusWidgetActions } from "./lotusActions";
// Track constructed instances so tests can assert exactly one processor was
// built across racing apply() calls, and can assert the pending one is
// destroyed on early teardown. `vi.hoisted` is required because `vi.mock`
// factories are hoisted above this file's other top-level statements.
const instances = vi.hoisted(
() => [] as { destroy: ReturnType<typeof vi.fn> }[],
() =>
[] as {
config: { model: string };
destroy: ReturnType<typeof vi.fn>;
setMicMuted: ReturnType<typeof vi.fn>;
}[],
);
// Ordered log of the heavy-lifting calls, to assert prepare runs before
// setProcessor (#7).
const calls = vi.hoisted(() => [] as string[]);
vi.mock("./lotusDenoiseProcessor", () => ({
LotusDenoiseProcessor: class {
public destroy = vi.fn().mockResolvedValue(undefined);
public constructor() {
public setMicMuted = vi.fn();
public constructor(public config: { model: string }) {
instances.push(this);
}
},
prepareDenoiseAssets: vi.fn(async () => {
calls.push("prepare");
await Promise.resolve();
}),
releasePreparedDenoiseAssets: vi.fn().mockResolvedValue(undefined),
}));
const hostSend = vi.hoisted(() => vi.fn().mockResolvedValue({}));
vi.mock("../widget", () => ({
widget: { api: { transport: { send: hostSend } } },
}));
/** A promise plus externally-callable resolve, for controlling ordering. */
@@ -42,19 +61,23 @@ function deferred<T>(): {
function makeRoomAndVm(mic: {
getProcessor: () => unknown;
setProcessor: ReturnType<typeof vi.fn>;
isMuted?: boolean;
}): {
vm: CallViewModel;
room: { localParticipant: Record<string, unknown> };
firePublished: () => void;
fire: (event: string, arg?: unknown) => void;
} {
const handlers = new Map<string, () => void>();
const handlers = new Map<string, (arg?: unknown) => void>();
const localParticipant = {
getTrackPublication: (
source: Track.Source,
): { track: typeof mic } | undefined =>
source === Track.Source.Microphone ? { track: mic } : undefined,
on: (event: string, cb: () => void): Map<string, () => void> =>
handlers.set(event, cb),
on: (
event: string,
cb: (arg?: unknown) => void,
): Map<string, (arg?: unknown) => void> => handlers.set(event, cb),
off: (event: string): boolean => handlers.delete(event),
};
const room = { localParticipant };
@@ -74,11 +97,19 @@ function makeRoomAndVm(mic: {
vm,
room,
firePublished: () => handlers.get(ParticipantEvent.LocalTrackPublished)?.(),
fire: (event, arg) => handlers.get(event)?.(arg),
};
}
/** Let the async apply() chain (attach → retry → host notify) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
beforeEach(() => {
instances.length = 0;
calls.length = 0;
hostSend.mockClear();
// `lotusParam`/`lotusFlag` cache the URL params at first read; seed the hash
// before startLotusDenoise() so the dedicated `lotusDenoiseSource` flag reads on.
window.location.hash = "#/room?lotusDenoiseSource=1";
@@ -95,6 +126,7 @@ describe("startLotusDenoise", () => {
const mic = {
getProcessor: (): unknown => attached,
setProcessor: vi.fn(async (p: unknown) => {
calls.push("setProcessor");
await setProcessorDeferred.promise;
attached = p;
}),
@@ -102,6 +134,8 @@ describe("startLotusDenoise", () => {
const { vm, firePublished } = makeRoomAndVm(mic);
startLotusDenoise(vm);
// [#7] The heavy assets were kicked off before setProcessor() was called.
expect(calls).toEqual(["prepare", "setProcessor"]);
// Simulate a second LocalTrackPublished (e.g. camera) firing before the
// first setProcessor() has resolved.
firePublished();
@@ -138,4 +172,103 @@ describe("startLotusDenoise", () => {
setProcessorDeferred.resolve();
await Promise.resolve();
});
test("reports the active model to the host once attached (#8)", async () => {
let attached: unknown;
const mic = {
getProcessor: (): unknown => attached,
setProcessor: vi.fn(async (p: unknown) => {
await Promise.resolve();
attached = p;
}),
};
const { vm } = makeRoomAndVm(mic);
startLotusDenoise(vm);
await flush();
expect(hostSend).toHaveBeenCalledWith(LotusWidgetActions.DenoiseState, {
active: true,
model: "rnnoise",
});
});
test("retries once with rnnoise when the requested model fails to init (#8)", async () => {
window.location.hash =
"#/room?lotusDenoiseSource=1&lotusModel=deepfilternet";
let attached: unknown;
const mic = {
getProcessor: (): unknown => attached,
setProcessor: vi.fn(async (p: { config: { model: string } }) => {
await Promise.resolve();
if (p.config.model === "deepfilternet")
throw new Error("dfn assets missing");
attached = p;
}),
};
const { vm } = makeRoomAndVm(mic);
startLotusDenoise(vm);
await flush();
expect(instances.map((i) => i.config.model)).toEqual([
"deepfilternet",
"rnnoise",
]);
expect(mic.setProcessor).toHaveBeenCalledTimes(2);
expect(hostSend).toHaveBeenCalledTimes(1);
expect(hostSend).toHaveBeenCalledWith(LotusWidgetActions.DenoiseState, {
active: true,
model: "rnnoise",
});
});
test("notifies the host with the error when the rnnoise fallback also fails (#8)", async () => {
window.location.hash = "#/room?lotusDenoiseSource=1&lotusModel=dtln";
const mic = {
getProcessor: (): unknown => undefined,
setProcessor: vi.fn(async () => {
await Promise.resolve();
throw new Error("no worklet for you");
}),
};
const { vm } = makeRoomAndVm(mic);
startLotusDenoise(vm);
await flush();
expect(instances.map((i) => i.config.model)).toEqual(["dtln", "rnnoise"]);
expect(hostSend).toHaveBeenCalledTimes(1);
expect(hostSend).toHaveBeenCalledWith(LotusWidgetActions.DenoiseState, {
active: false,
error: "no worklet for you",
model: "rnnoise",
});
});
test("mirrors mic TrackMuted/TrackUnmuted onto the processor (#9)", async () => {
let attached: unknown;
const mic = {
isMuted: true,
getProcessor: (): unknown => attached,
setProcessor: vi.fn(async (p: unknown) => {
await Promise.resolve();
attached = p;
}),
};
const { vm, fire } = makeRoomAndVm(mic);
startLotusDenoise(vm);
// Seeded from the mic's current state before setProcessor().
expect(instances[0].setMicMuted).toHaveBeenLastCalledWith(true);
await flush();
const micPub = { source: Track.Source.Microphone };
const camPub = { source: Track.Source.Camera };
fire(ParticipantEvent.TrackUnmuted, micPub);
expect(instances[0].setMicMuted).toHaveBeenLastCalledWith(false);
fire(ParticipantEvent.TrackMuted, micPub);
expect(instances[0].setMicMuted).toHaveBeenLastCalledWith(true);
// Camera mute must not touch the audio context.
instances[0].setMicMuted.mockClear();
fire(ParticipantEvent.TrackMuted, camPub);
fire(ParticipantEvent.TrackUnmuted, camPub);
expect(instances[0].setMicMuted).not.toHaveBeenCalled();
});
});