fix(lotus): don't start two denoise processors on racing LocalTrackPublished

apply() guarded on mic.getProcessor(), which is only set after setProcessor()
resolves (after the whole wasm/model load), so mic-published followed by
camera-published constructed two processors — two AudioContexts, two model
loads, double lock hold time. Track the in-flight processor per room, skip
apply() while one is pending, and destroy a pending processor if the module
is torn down before setProcessor resolves. Unit-tested.

Fixes #10

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-12 11:34:08 -04:00
co-authored by Claude Opus 5
parent 936a083533
commit 59e0c852ee
2 changed files with 171 additions and 5 deletions
+141
View File
@@ -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<typeof vi.fn> }[],
);
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<T>(): {
promise: Promise<T>;
resolve: (v: T) => void;
} {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
function makeRoomAndVm(mic: {
getProcessor: () => unknown;
setProcessor: ReturnType<typeof vi.fn>;
}): {
vm: CallViewModel;
room: { localParticipant: Record<string, unknown> };
firePublished: () => void;
} {
const handlers = new Map<string, () => 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),
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<void>();
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<void>();
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();
});
});
+30 -5
View File
@@ -97,13 +97,29 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
room.localParticipant.getTrackPublication(Track.Source.Microphone) room.localParticipant.getTrackPublication(Track.Source.Microphone)
?.track as LocalAudioTrack | undefined; ?.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<LivekitRoom, LotusDenoiseProcessor>();
const apply = (room: LivekitRoom): void => { const apply = (room: LivekitRoom): void => {
const mic = micOf(room); const mic = micOf(room);
if (mic && !mic.getProcessor()) { if (!mic || mic.getProcessor() || pendingProcessors.has(room)) return;
void mic const processor = new LotusDenoiseProcessor(config);
.setProcessor(new LotusDenoiseProcessor(config)) pendingProcessors.set(room, processor);
.catch((e) => logger.warn("[lotus] denoise setProcessor failed", e)); 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<LivekitRoom, () => void>(); const roomListeners = new Map<LivekitRoom, () => void>();
@@ -149,6 +165,15 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
for (const room of rooms) { for (const room of rooms) {
const mic = micOf(room); const mic = micOf(room);
if (mic?.getProcessor()) void mic.stopProcessor(); 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();
}; };
} }