While the mic is muted LiveKit keeps the capture alive and merely disables the published MediaStreamTrack, so VAD says nothing. lotusMutedSpeech taps a CLONE of the published track (post-denoise when the in-source processor is active), samples RMS at 10 Hz through an AnalyserNode and runs a hysteresis gate (300 ms of voice on, 800 ms of quiet off, threshold ≈ −36 dBFS). The tap only exists while a muted mic publication exists; the flag is set on the local entry of io.lotus.call_state only and is never sent to other participants. Gate is unit-tested; verified end-to-end: muted with a tone mic → true within 2.5 s, silence → false, unmute → false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
/*
|
||
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 {
|
||
type LocalTrackPublication,
|
||
type Room as LivekitRoom,
|
||
RoomEvent,
|
||
Track,
|
||
} from "livekit-client";
|
||
import { Observable, distinctUntilChanged, switchMap } from "rxjs";
|
||
|
||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||
|
||
/**
|
||
* [lotus #37] "Talking while muted" detection for the LOCAL participant.
|
||
*
|
||
* While the mic is muted LiveKit keeps the capture alive and merely disables
|
||
* the published MediaStreamTrack, so VAD/`speaking` is false and nothing
|
||
* tells the user they are talking into a muted mic. This taps a CLONE of the
|
||
* published track (the post-processor track when the in-source denoiser is
|
||
* active, so keyboard noise doesn't count), samples RMS at ~10 Hz and emits a
|
||
* debounced boolean. Zero cost when unmuted (tap torn down), local-only —
|
||
* the flag rides `io.lotus.call_state` to the host and never reaches other
|
||
* participants.
|
||
*/
|
||
|
||
export const MUTED_SPEECH_RMS = 0.015; // ≈ −36 dBFS; normal speech into a headset is 0.05–0.3
|
||
const SAMPLE_MS = 100;
|
||
const ON_SAMPLES = 3; // 300 ms of voice before we say "talking"
|
||
const OFF_SAMPLES = 8; // 800 ms of quiet before we drop it
|
||
|
||
/** Pure hysteresis gate over successive RMS samples (unit-tested). */
|
||
export class MutedSpeechGate {
|
||
private above = 0;
|
||
|
||
private below = 0;
|
||
|
||
private on = false;
|
||
|
||
public constructor(private readonly threshold = MUTED_SPEECH_RMS) {}
|
||
|
||
public push(rms: number): boolean {
|
||
if (rms >= this.threshold) {
|
||
this.above += 1;
|
||
this.below = 0;
|
||
if (!this.on && this.above >= ON_SAMPLES) this.on = true;
|
||
} else {
|
||
this.below += 1;
|
||
this.above = 0;
|
||
if (this.on && this.below >= OFF_SAMPLES) this.on = false;
|
||
}
|
||
return this.on;
|
||
}
|
||
|
||
public get value(): boolean {
|
||
return this.on;
|
||
}
|
||
}
|
||
|
||
const mutedMicTrack = (room: LivekitRoom): MediaStreamTrack | null => {
|
||
const pub: LocalTrackPublication | undefined =
|
||
room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||
const track = pub?.track?.mediaStreamTrack;
|
||
return pub?.isMuted && track && track.readyState === "live" ? track : null;
|
||
};
|
||
|
||
/**
|
||
* Emits while the local mic is muted and voice is detected on it. Emits
|
||
* `false` whenever the mic is unmuted, unpublished or the connection changes.
|
||
*/
|
||
export function observeSpeakingWhileMuted$(
|
||
vm: CallViewModel,
|
||
): Observable<boolean> {
|
||
return vm.allConnections$.pipe(
|
||
switchMap(
|
||
(data) =>
|
||
new Observable<boolean>((subscriber) => {
|
||
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
||
let ctx: AudioContext | null = null;
|
||
let clone: MediaStreamTrack | null = null;
|
||
let timer: ReturnType<typeof setInterval> | undefined;
|
||
let tapped: MediaStreamTrack | null = null;
|
||
|
||
const stopTap = (): void => {
|
||
if (timer !== undefined) clearInterval(timer);
|
||
timer = undefined;
|
||
clone?.stop();
|
||
clone = null;
|
||
void ctx?.close().catch(() => undefined);
|
||
ctx = null;
|
||
tapped = null;
|
||
subscriber.next(false);
|
||
};
|
||
|
||
const startTap = (source: MediaStreamTrack): void => {
|
||
try {
|
||
clone = source.clone();
|
||
clone.enabled = true; // the source is disabled by the mute — the clone must not be
|
||
ctx = new AudioContext();
|
||
const analyser = ctx.createAnalyser();
|
||
analyser.fftSize = 1024;
|
||
ctx
|
||
.createMediaStreamSource(new MediaStream([clone]))
|
||
.connect(analyser);
|
||
const buf = new Float32Array(analyser.fftSize);
|
||
const gate = new MutedSpeechGate();
|
||
tapped = source;
|
||
timer = setInterval(() => {
|
||
analyser.getFloatTimeDomainData(buf);
|
||
let sum = 0;
|
||
for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
|
||
subscriber.next(gate.push(Math.sqrt(sum / buf.length)));
|
||
}, SAMPLE_MS);
|
||
} catch {
|
||
stopTap();
|
||
}
|
||
};
|
||
|
||
const reconcile = (): void => {
|
||
const track =
|
||
rooms.map(mutedMicTrack).find((t) => t !== null) ?? null;
|
||
if (track === tapped) return;
|
||
if (tapped) stopTap();
|
||
if (track) startTap(track);
|
||
};
|
||
|
||
const events = [
|
||
RoomEvent.TrackMuted,
|
||
RoomEvent.TrackUnmuted,
|
||
RoomEvent.LocalTrackPublished,
|
||
RoomEvent.LocalTrackUnpublished,
|
||
RoomEvent.Reconnected,
|
||
RoomEvent.Disconnected,
|
||
] as const;
|
||
rooms.forEach((room) =>
|
||
events.forEach((ev) => room.on(ev, reconcile)),
|
||
);
|
||
subscriber.next(false);
|
||
reconcile();
|
||
return () => {
|
||
rooms.forEach((room) =>
|
||
events.forEach((ev) => room.off(ev, reconcile)),
|
||
);
|
||
if (tapped) stopTap();
|
||
};
|
||
}),
|
||
),
|
||
distinctUntilChanged(),
|
||
);
|
||
}
|