2026-09-19 22:45:23 -04:00
|
|
|
|
/*
|
|
|
|
|
|
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 {
|
2026-09-25 12:08:57 -04:00
|
|
|
|
type Observable,
|
|
|
|
|
|
distinctUntilChanged,
|
|
|
|
|
|
map,
|
|
|
|
|
|
scan,
|
|
|
|
|
|
startWith,
|
|
|
|
|
|
} from "rxjs";
|
2026-09-19 22:45:23 -04:00
|
|
|
|
|
|
|
|
|
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
2026-09-25 12:08:57 -04:00
|
|
|
|
import { type LocalMicSample, observeLocalMicSample$ } from "./lotusMicLevel";
|
2026-09-19 22:45:23 -04:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* [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
|
2026-09-25 12:08:57 -04:00
|
|
|
|
* debounced boolean. Local-only —
|
2026-09-19 22:45:23 -04:00
|
|
|
|
* 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.
|
2026-09-25 12:08:57 -04:00
|
|
|
|
* [lotus #146] Fed by the shared local mic sampler (lotusMicLevel.ts), which
|
|
|
|
|
|
* also drives the host's mic level meter while unmuted.
|
2026-09-19 22:45:23 -04:00
|
|
|
|
*/
|
|
|
|
|
|
export function observeSpeakingWhileMuted$(
|
|
|
|
|
|
vm: CallViewModel,
|
|
|
|
|
|
): Observable<boolean> {
|
2026-09-25 12:08:57 -04:00
|
|
|
|
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),
|
2026-09-19 22:45:23 -04:00
|
|
|
|
distinctUntilChanged(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|