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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user