From 73bf2133dc14fcbcfe1534c1ef5a5e7cfafdd7b7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 01:17:06 -0400 Subject: [PATCH] fix(lotus): restart the shared mic sampler only when the LiveKit rooms change (#45) observeLocalMicSample$ switchMapped on every allConnections$ emission, which re-emits far more often than the set of rooms changes. Each one stopped the clone, closed the AudioContext and emitted null, and the null reset the muted-speech gate, so the "You're muted" nudge could never stay true long enough to fire. Map to the room list and only restart when it actually changes. Fixes #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/lotus/lotusMicLevel.test.ts | 13 ++++++++++++- src/lotus/lotusMicLevel.ts | 13 +++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/lotus/lotusMicLevel.test.ts b/src/lotus/lotusMicLevel.test.ts index 239ad608..d6056e3b 100644 --- a/src/lotus/lotusMicLevel.test.ts +++ b/src/lotus/lotusMicLevel.test.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it } from "vitest"; -import { MicLevelQuantizer } from "./lotusMicLevel"; +import { MicLevelQuantizer, sameRooms } from "./lotusMicLevel"; describe("MicLevelQuantizer", () => { it("maps RMS to 0–3 bars", () => { @@ -35,3 +35,14 @@ describe("MicLevelQuantizer", () => { expect(q.push(0)).toBe(0); }); }); + +describe("sameRooms (EC #45)", () => { + it("compares rooms by identity and order", () => { + const a = {}; + const b = {}; + expect(sameRooms([a, b], [a, b])).toBe(true); + expect(sameRooms([a], [a, b])).toBe(false); + expect(sameRooms([a, b], [b, a])).toBe(false); + expect(sameRooms([{}], [{}])).toBe(false); + }); +}); diff --git a/src/lotus/lotusMicLevel.ts b/src/lotus/lotusMicLevel.ts index 6fc2f298..3e148532 100644 --- a/src/lotus/lotusMicLevel.ts +++ b/src/lotus/lotusMicLevel.ts @@ -46,6 +46,10 @@ const micPublication = ( : null; }; +/** Pure: the same LiveKit rooms, in the same order (by identity). */ +export const sameRooms = (a: readonly T[], b: readonly T[]): boolean => + a.length === b.length && a.every((r, i) => r === b[i]); + const samplers = new WeakMap< CallViewModel, Observable @@ -62,10 +66,15 @@ export function observeLocalMicSample$( const cached = samplers.get(vm); if (cached) return cached; const sampler$ = vm.allConnections$.pipe( + map((data) => data.getConnections().map((c) => c.livekitRoom)), + // [EC #45] allConnections$ re-emits far more often than the set of + // LiveKit rooms changes; restarting the tap each time closed the + // AudioContext ~1×/s and emitted null, which reset the muted-speech + // gate so the "You're muted" nudge could never fire. + distinctUntilChanged(sameRooms), switchMap( - (data) => + (rooms) => new Observable((subscriber) => { - const rooms = data.getConnections().map((c) => c.livekitRoom); let ctx: AudioContext | null = null; let clone: MediaStreamTrack | null = null; let timer: ReturnType | undefined;