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
+30 -5
View File
@@ -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<LivekitRoom, LotusDenoiseProcessor>();
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<LivekitRoom, () => 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();
};
}