fix(lotus): denoise — prefetch assets, RNNoise fallback + host notify, suspend while muted, mono/delay graph, typed modules
- Assets (context, worklets, wasm, DFN core) are prepared as soon as the flag is seen, so init() under LiveKit's trackChangeLock only wires already-loaded pieces; resume timeout 3 s -> 500 ms (#7). - init failure retries once with rnnoise; success/failure is reported to the host as io.lotus.denoise_state so the UI can reflect reality (#8). - Mic TrackMuted/TrackUnmuted suspend/resume the processor's context so no inference runs on silence (#9). - Every node is explicit mono; the dry path gets a per-model DelayNode so the floor mix no longer comb-filters (#24, #25). - DTLN/DFN dynamic imports are typed and their exports asserted at load, feeding the #8 fallback instead of failing silently (#26). Unit-tested (13 tests across the two files). Fixes #7 Fixes #8 Fixes #9 Fixes #24 Fixes #25 Fixes #26 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
872b877248
commit
e504a31efd
+89
-15
@@ -10,14 +10,23 @@ import {
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { lotusFlag, lotusParam } from "./lotusWidget";
|
||||
import {
|
||||
LotusWidgetActions,
|
||||
lotusFlag,
|
||||
lotusParam,
|
||||
lotusSendToHost,
|
||||
} from "./lotusWidget";
|
||||
import {
|
||||
type LotusDenoiseConfig,
|
||||
type LotusDenoiseModel,
|
||||
LotusDenoiseProcessor,
|
||||
prepareDenoiseAssets,
|
||||
releasePreparedDenoiseAssets,
|
||||
} from "./lotusDenoiseProcessor";
|
||||
|
||||
/**
|
||||
@@ -93,6 +102,12 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
|
||||
: 0.15,
|
||||
};
|
||||
|
||||
// [lotus #7] Start fetching wasm / creating the AudioContext / addModule-ing
|
||||
// the worklet (and loading the DFN model) NOW, before any mic track exists.
|
||||
// `setProcessor()` holds LiveKit's trackChangeLock while awaiting `init()`,
|
||||
// so anything still loading there freezes mute/unmute/device-switch.
|
||||
void prepareDenoiseAssets(config);
|
||||
|
||||
const micOf = (room: LivekitRoom): LocalAudioTrack | undefined =>
|
||||
room.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
?.track as LocalAudioTrack | undefined;
|
||||
@@ -105,23 +120,73 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
|
||||
// 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>();
|
||||
let stopped = false;
|
||||
|
||||
const attach = async (
|
||||
room: LivekitRoom,
|
||||
mic: LocalAudioTrack,
|
||||
model: LotusDenoiseModel,
|
||||
): Promise<void> => {
|
||||
const processor = new LotusDenoiseProcessor({ ...config, model });
|
||||
pendingProcessors.set(room, processor);
|
||||
// [lotus #9] Seed the mute state so a processor attached while already
|
||||
// muted starts suspended rather than burning inference on silence.
|
||||
processor.setMicMuted(mic.isMuted);
|
||||
try {
|
||||
await mic.setProcessor(processor);
|
||||
} 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 apply = (room: LivekitRoom): void => {
|
||||
const mic = micOf(room);
|
||||
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);
|
||||
});
|
||||
void (async () => {
|
||||
let model = config.model;
|
||||
try {
|
||||
try {
|
||||
await attach(room, mic, model);
|
||||
} catch (e) {
|
||||
logger.warn("[lotus] denoise setProcessor failed", e);
|
||||
// [lotus #8] A failed init leaves LiveKit with no processor (it only
|
||||
// assigns after init resolves), so retry once with the smallest,
|
||||
// most portable tier before giving up.
|
||||
if (model === "rnnoise" || stopped) throw e;
|
||||
model = "rnnoise";
|
||||
await attach(room, mic, model);
|
||||
}
|
||||
// [lotus #8] Tell the host what is ACTUALLY running so its toggle
|
||||
// reflects reality (including the fallback model).
|
||||
lotusSendToHost(LotusWidgetActions.DenoiseState, {
|
||||
active: true,
|
||||
model,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn("[lotus] denoise unavailable; publishing raw mic", e);
|
||||
lotusSendToHost(LotusWidgetActions.DenoiseState, {
|
||||
active: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
model,
|
||||
});
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// [lotus #9] Suspend/resume the processor's own AudioContext with the mic's
|
||||
// mute state (LiveKit keeps the muted track flowing silent frames, so the
|
||||
// worklet would otherwise run inference the whole time the user is muted).
|
||||
const onMuteChange =
|
||||
(room: LivekitRoom, muted: boolean) =>
|
||||
(pub: TrackPublication): void => {
|
||||
if (pub.source !== Track.Source.Microphone) return;
|
||||
const p = pendingProcessors.get(room) ?? micOf(room)?.getProcessor();
|
||||
if (p instanceof LotusDenoiseProcessor) p.setMicMuted(muted);
|
||||
};
|
||||
|
||||
const roomListeners = new Map<LivekitRoom, () => void>();
|
||||
let rooms: LivekitRoom[] = [];
|
||||
|
||||
@@ -143,22 +208,29 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
|
||||
// Re-attach on every (re)publish — this is what makes denoise survive
|
||||
// reconnects (A7), unlike the old getUserMedia patch.
|
||||
const onPublished = (): void => apply(room);
|
||||
const onMuted = onMuteChange(room, true);
|
||||
const onUnmuted = onMuteChange(room, false);
|
||||
room.localParticipant.on(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
onPublished,
|
||||
);
|
||||
roomListeners.set(room, () =>
|
||||
room.localParticipant.on(ParticipantEvent.TrackMuted, onMuted);
|
||||
room.localParticipant.on(ParticipantEvent.TrackUnmuted, onUnmuted);
|
||||
roomListeners.set(room, () => {
|
||||
room.localParticipant.off(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
onPublished,
|
||||
),
|
||||
);
|
||||
);
|
||||
room.localParticipant.off(ParticipantEvent.TrackMuted, onMuted);
|
||||
room.localParticipant.off(ParticipantEvent.TrackUnmuted, onUnmuted);
|
||||
});
|
||||
apply(room);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
sub.unsubscribe();
|
||||
for (const off of roomListeners.values()) off();
|
||||
roomListeners.clear();
|
||||
@@ -175,5 +247,7 @@ export function startLotusDenoise(vm: CallViewModel): () => void {
|
||||
}
|
||||
}
|
||||
pendingProcessors.clear();
|
||||
// [lotus #7] Close any prepared-but-never-claimed context (e.g. no mic).
|
||||
void releasePreparedDenoiseAssets();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user