/* 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 Observable, distinctUntilChanged, map, scan, startWith, } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { type LocalMicSample, observeLocalMicSample$ } from "./lotusMicLevel"; /** * [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. 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 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; } } /** * 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. * [lotus #146] Fed by the shared local mic sampler (lotusMicLevel.ts), which * also drives the host's mic level meter while unmuted. */ export function observeSpeakingWhileMuted$( vm: CallViewModel, ): Observable { return observeLocalMicSample$(vm).pipe( scan((gate: MutedSpeechGate | null, sample: LocalMicSample | null) => { if (!sample?.muted) return null; const g = gate ?? new MutedSpeechGate(); g.push(sample.rms); return g; }, null), map((gate) => gate?.value ?? false), startWith(false), distinctUntilChanged(), ); }