diff --git a/src/lotus/lotusDenoise.test.ts b/src/lotus/lotusDenoise.test.ts new file mode 100644 index 00000000..8006c8a2 --- /dev/null +++ b/src/lotus/lotusDenoise.test.ts @@ -0,0 +1,141 @@ +/* +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 { ParticipantEvent, Track } from "livekit-client"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { startLotusDenoise } from "./lotusDenoise"; + +// 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 }[], +); +vi.mock("./lotusDenoiseProcessor", () => ({ + LotusDenoiseProcessor: class { + public destroy = vi.fn().mockResolvedValue(undefined); + public constructor() { + instances.push(this); + } + }, +})); + +/** A promise plus externally-callable resolve, for controlling ordering. */ +function deferred(): { + promise: Promise; + resolve: (v: T) => void; +} { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function makeRoomAndVm(mic: { + getProcessor: () => unknown; + setProcessor: ReturnType; +}): { + vm: CallViewModel; + room: { localParticipant: Record }; + firePublished: () => void; +} { + const handlers = new Map void>(); + const localParticipant = { + getTrackPublication: ( + source: Track.Source, + ): { track: typeof mic } | undefined => + source === Track.Source.Microphone ? { track: mic } : undefined, + on: (event: string, cb: () => void): Map void> => + handlers.set(event, cb), + off: (event: string): boolean => handlers.delete(event), + }; + const room = { localParticipant }; + const vm = { + allConnections$: { + subscribe: ( + cb: (data: { + getConnections: () => { livekitRoom: unknown }[]; + }) => void, + ) => { + cb({ getConnections: () => [{ livekitRoom: room }] }); + return { unsubscribe: (): void => undefined }; + }, + }, + } as unknown as CallViewModel; + return { + vm, + room, + firePublished: () => handlers.get(ParticipantEvent.LocalTrackPublished)?.(), + }; +} + +beforeEach(() => { + instances.length = 0; + // `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"; +}); + +afterEach(() => { + vi.resetModules(); +}); + +describe("startLotusDenoise", () => { + test("racing LocalTrackPublished events only construct one processor", async () => { + const setProcessorDeferred = deferred(); + let attached: unknown; + const mic = { + getProcessor: (): unknown => attached, + setProcessor: vi.fn(async (p: unknown) => { + await setProcessorDeferred.promise; + attached = p; + }), + }; + const { vm, firePublished } = makeRoomAndVm(mic); + + startLotusDenoise(vm); + // Simulate a second LocalTrackPublished (e.g. camera) firing before the + // first setProcessor() has resolved. + firePublished(); + + expect(mic.setProcessor).toHaveBeenCalledTimes(1); + expect(instances).toHaveLength(1); + + setProcessorDeferred.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Once attached, a further publish should be a no-op (mic.getProcessor() + // is now set). + firePublished(); + expect(mic.setProcessor).toHaveBeenCalledTimes(1); + }); + + test("destroys the in-flight processor if torn down before setProcessor resolves", async () => { + const setProcessorDeferred = deferred(); + const mic = { + getProcessor: (): unknown => undefined, + setProcessor: vi.fn(async () => setProcessorDeferred.promise), + }; + const { vm } = makeRoomAndVm(mic); + + const teardown = startLotusDenoise(vm); + expect(instances).toHaveLength(1); + + teardown(); + + expect(instances[0].destroy).toHaveBeenCalledTimes(1); + + // Resolving afterwards must not throw/reject unhandled. + setProcessorDeferred.resolve(); + await Promise.resolve(); + }); +}); diff --git a/src/lotus/lotusDenoise.ts b/src/lotus/lotusDenoise.ts index 8f06aa41..7c87e6d0 100644 --- a/src/lotus/lotusDenoise.ts +++ b/src/lotus/lotusDenoise.ts @@ -97,13 +97,29 @@ export function startLotusDenoise(vm: CallViewModel): () => void { room.localParticipant.getTrackPublication(Track.Source.Microphone) ?.track as LocalAudioTrack | undefined; + // [lotus] `mic.getProcessor()` only becomes set once `setProcessor()` + // resolves — i.e. after the whole wasm/model load. LiveKit fires + // `LocalTrackPublished` once per local track (mic, then camera on join with + // video), so two calls to `apply()` can both observe `!mic.getProcessor()` + // and race to construct a second `LotusDenoiseProcessor` (a second + // AudioContext + model load) before the first has attached. Track an + // in-flight setProcessor per room and skip `apply()` while one is pending. + const pendingProcessors = new Map(); + const apply = (room: LivekitRoom): void => { const mic = micOf(room); - if (mic && !mic.getProcessor()) { - void mic - .setProcessor(new LotusDenoiseProcessor(config)) - .catch((e) => logger.warn("[lotus] denoise setProcessor failed", e)); - } + if (!mic || mic.getProcessor() || pendingProcessors.has(room)) return; + const processor = new LotusDenoiseProcessor(config); + pendingProcessors.set(room, processor); + void mic + .setProcessor(processor) + .catch((e) => logger.warn("[lotus] denoise setProcessor failed", e)) + .finally(() => { + // Only clear if we're still the pending entry (a teardown that ran + // while this was in flight may have already replaced/removed it). + if (pendingProcessors.get(room) === processor) + pendingProcessors.delete(room); + }); }; const roomListeners = new Map void>(); @@ -149,6 +165,15 @@ export function startLotusDenoise(vm: CallViewModel): () => void { for (const room of rooms) { const mic = micOf(room); if (mic?.getProcessor()) void mic.stopProcessor(); + else { + // [lotus] A setProcessor() call may still be in flight (mid wasm/model + // load) when teardown runs, in which case `mic.getProcessor()` is + // still undefined and `stopProcessor()` above is a no-op. Destroy the + // pending processor directly so its AudioContext/graph don't leak. + const pending = pendingProcessors.get(room); + if (pending) void pending.destroy().catch(() => undefined); + } } + pendingProcessors.clear(); }; }