feat(lotus): report speakingWhileMuted for the local participant in io.lotus.call_state (#37)

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
This commit is contained in:
Lotus CI
2026-09-19 22:45:23 -04:00
co-authored by Claude Opus 5
parent 5421d545c4
commit c2267800be
4 changed files with 227 additions and 29 deletions
+41 -28
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, of, type Subscription } from "rxjs";
import { combineLatest, of, startWith, type Subscription } from "rxjs";
import {
distinctUntilChanged,
map,
@@ -16,6 +16,7 @@ import {
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { widget } from "../widget";
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
import { observeSpeakingWhileMuted$ } from "./lotusMutedSpeech";
interface ParticipantState {
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
@@ -25,6 +26,11 @@ interface ParticipantState {
speaking: boolean;
audioEnabled: boolean;
videoEnabled: boolean;
/**
* [lotus #37] LOCAL participant only: voice detected on the mic while it is
* muted (see lotusMutedSpeech.ts). Absent for remote participants.
*/
speakingWhileMuted?: boolean;
}
/**
@@ -45,7 +51,8 @@ function participantsEqual(
p.userId === b[i].userId &&
p.speaking === b[i].speaking &&
p.audioEnabled === b[i].audioEnabled &&
p.videoEnabled === b[i].videoEnabled,
p.videoEnabled === b[i].videoEnabled &&
p.speakingWhileMuted === b[i].speakingWhileMuted,
)
);
}
@@ -66,34 +73,40 @@ export function startLotusCallState(vm: CallViewModel): () => void {
// lotusDecorations.ts.
if (!widget) return () => undefined;
const sub: Subscription = vm.userMedia$
.pipe(
switchMap((members) =>
members.length === 0
? of([] as ParticipantState[])
: combineLatest(
members.map((m) =>
combineLatest([
m.speaking$,
m.audioEnabled$,
m.videoEnabled$,
]).pipe(
map(
([
speaking,
audioEnabled,
videoEnabled,
]): ParticipantState => ({
id: m.id,
userId: m.userId,
speaking,
audioEnabled,
videoEnabled,
}),
),
),
const participants$ = vm.userMedia$.pipe(
switchMap((members) =>
members.length === 0
? of([] as (ParticipantState & { local: boolean })[])
: combineLatest(
members.map((m) =>
combineLatest([
m.speaking$,
m.audioEnabled$,
m.videoEnabled$,
]).pipe(
map(([speaking, audioEnabled, videoEnabled]) => ({
id: m.id,
userId: m.userId,
speaking,
audioEnabled,
videoEnabled,
local: m.local === true,
})),
),
),
),
),
);
const sub: Subscription = combineLatest([
participants$,
observeSpeakingWhileMuted$(vm).pipe(startWith(false)),
])
.pipe(
map(([members, speakingWhileMuted]): ParticipantState[] =>
members.map(({ local, ...p }) =>
local ? { ...p, speakingWhileMuted } : p,
),
),
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
// the throttle window isn't spent re-emitting an unchanged value.