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:
co-authored by
Claude Opus 5
parent
5421d545c4
commit
c2267800be
@@ -53,7 +53,11 @@ function mockMember(id: string, userId: string): Member {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mockVm(members: Member[]): CallViewModel {
|
function mockVm(members: Member[]): CallViewModel {
|
||||||
return { userMedia$: of(members) } as unknown as CallViewModel;
|
return {
|
||||||
|
userMedia$: of(members),
|
||||||
|
// no livekit connections → the muted-speech tap never starts
|
||||||
|
allConnections$: of({ getConnections: () => [] }),
|
||||||
|
} as unknown as CallViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function participantsOf(call: number): unknown[] {
|
function participantsOf(call: number): unknown[] {
|
||||||
|
|||||||
+41
-28
@@ -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.
|
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 {
|
import {
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
map,
|
map,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
|
||||||
|
import { observeSpeakingWhileMuted$ } from "./lotusMutedSpeech";
|
||||||
|
|
||||||
interface ParticipantState {
|
interface ParticipantState {
|
||||||
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
|
/** EC media id (`${userId}:${deviceId}`), stable per participant device. */
|
||||||
@@ -25,6 +26,11 @@ interface ParticipantState {
|
|||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
videoEnabled: 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.userId === b[i].userId &&
|
||||||
p.speaking === b[i].speaking &&
|
p.speaking === b[i].speaking &&
|
||||||
p.audioEnabled === b[i].audioEnabled &&
|
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.
|
// lotusDecorations.ts.
|
||||||
if (!widget) return () => undefined;
|
if (!widget) return () => undefined;
|
||||||
|
|
||||||
const sub: Subscription = vm.userMedia$
|
const participants$ = vm.userMedia$.pipe(
|
||||||
.pipe(
|
switchMap((members) =>
|
||||||
switchMap((members) =>
|
members.length === 0
|
||||||
members.length === 0
|
? of([] as (ParticipantState & { local: boolean })[])
|
||||||
? of([] as ParticipantState[])
|
: combineLatest(
|
||||||
: combineLatest(
|
members.map((m) =>
|
||||||
members.map((m) =>
|
combineLatest([
|
||||||
combineLatest([
|
m.speaking$,
|
||||||
m.speaking$,
|
m.audioEnabled$,
|
||||||
m.audioEnabled$,
|
m.videoEnabled$,
|
||||||
m.videoEnabled$,
|
]).pipe(
|
||||||
]).pipe(
|
map(([speaking, audioEnabled, videoEnabled]) => ({
|
||||||
map(
|
id: m.id,
|
||||||
([
|
userId: m.userId,
|
||||||
speaking,
|
speaking,
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
]): ParticipantState => ({
|
local: m.local === true,
|
||||||
id: m.id,
|
})),
|
||||||
userId: m.userId,
|
|
||||||
speaking,
|
|
||||||
audioEnabled,
|
|
||||||
videoEnabled,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
// `speaking` flips rapidly; drop no-op repeats BEFORE throttling so
|
||||||
// the throttle window isn't spent re-emitting an unchanged value.
|
// the throttle window isn't spent re-emitting an unchanged value.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
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 { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { MutedSpeechGate } from "./lotusMutedSpeech";
|
||||||
|
|
||||||
|
test("needs 300 ms of voice to switch on and 800 ms of quiet to switch off", () => {
|
||||||
|
const g = new MutedSpeechGate(0.015);
|
||||||
|
expect(g.push(0.1)).toBe(false);
|
||||||
|
expect(g.push(0.1)).toBe(false);
|
||||||
|
expect(g.push(0.1)).toBe(true);
|
||||||
|
// a short dip does not drop it
|
||||||
|
for (let i = 0; i < 7; i++) expect(g.push(0.0)).toBe(true);
|
||||||
|
expect(g.push(0.0)).toBe(false);
|
||||||
|
// one loud sample after quiet does not re-trigger
|
||||||
|
expect(g.push(0.2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard-level noise below the threshold never triggers", () => {
|
||||||
|
const g = new MutedSpeechGate(0.015);
|
||||||
|
for (let i = 0; i < 50; i++) expect(g.push(0.01)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user