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:
co-authored by
Claude Opus 5
parent
936a083533
commit
59e0c852ee
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user