The host can now change noise suppression on the live mic without a
rejoin: off / browser-native / ML, the ML model, the gate and stacking
browser NS in front of the model.
- Model swaps preload the new model while the old one keeps running,
then swap the processor (no mic re-capture).
- Browser NS on/off re-captures the mic via restartTrack with the new
constraint (passing the deviceId, without which LiveKit drops every
constraint). The choice is kept in lotusCaptureOverride, which
ConnectionFactory reads, so a later SFU reconnect captures the same way.
- Switches are serialized; a burst collapses into the last one. Each one
reports io.lotus.denoise_state (now with `mode`).
Fixes found while testing in Chromium:
- The mic level meter (and muted-speech nudge) kept a clone of the
ORIGINAL mic track and never noticed LiveKit's restartTrack. Chrome
makes all open tracks of a device share audio processing, so every
re-capture silently kept the old noiseSuppression (asked false, got
true). The meter now drops its clone around our re-capture and
re-taps on TrackEvent.Restarted, which also fixes it listening to the
old device after a mic switch.
- LiveKit defaults voiceIsolation ON, a further voice filter in "off"
and ML modes where the platform implements it. It now follows browser
noise suppression.
Verified in a two-browser call: joined as browser and as ML(dtln), then
switched dtln -> deepfilternet -> rnnoise -> browser -> off; each step
applied within ~0.5 s and every re-capture got the constraints it asked
for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
- 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#7Fixes#8Fixes#9Fixes#24Fixes#25Fixes#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
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