The host had only a boolean `speaking` for yourself, throttled to 500 ms, so
nothing on the call bar showed that the mic was actually picking you up.
- lotusMicLevel.ts: one shared local mic sampler (a clone of the published
track, ~10 Hz RMS, while a mic is published, muted or not), shared per
call view model so there is one AudioContext.
- MicLevelQuantizer: 0–3 bars (≈ −46/−36/−26 dBFS), rising at once and
falling one bar per sample so it doesn't flicker between words.
- fromWidget io.lotus.mic_level { bars } only when the value changes; 0 while
muted or with no mic. Opt-in with the host state stream (lotusCallState=1).
- "Talking while muted" (#37) now reads the same sampler instead of running
its own; same gate and behaviour.
Verified in a local call with a fake-tone mic: the host receives 0–3 as the
tone rises/falls (about 4 messages/s while it changes), 0 on mute and levels
again on unmute; muted + tone still raises speakingWhileMuted and the host's
"You're muted" notice. Lotus/room suites pass on Node 22 (148 tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
85 lines
2.6 KiB
TypeScript
85 lines
2.6 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 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<boolean> {
|
||
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(),
|
||
);
|
||
}
|