From f1cfcc73773776593ca1d68bc7494675b7056fb3 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 12 Sep 2026 11:34:08 -0400 Subject: [PATCH] fix(lotus): implement deafen via EC's global output mute, not setVolume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deafen wrote RemoteParticipant.setVolume(0, Microphone), which EC's own createVolumeControls overwrote with the tile volume every time sink$ re-emitted (every join / re-resolution) — so anyone joining while you were deafened was audible — and undeafen blanket-wrote 1 to every participant, clobbering per-tile volume/mute. The ParticipantConnected listeners also hung off livekitRoomItems$, which is empty while alone, so the first joiner could be heard briefly. Deafen now drives setAudioEnabled$ -> muteAllAudio$, the `muted` prop InCallView already passes to every audio renderer, which also silences Track.Source.Unknown soundboard audio (the known P6-2 gap). Undeafen restores the user's own output state and never touches the mute-all setting. Re-applying the same state is a no-op so resendForkState() is safe. Screenshare-audio-only mute keeps setVolume(ScreenShareAudio) but only restores participants it muted itself. Rooms come from allConnections$ like the sibling modules. Unit-tested. Fixes #1 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusDeafen.test.ts | 154 ++++++++++++++++++++++++++++++++++ src/lotus/lotusDeafen.ts | 134 +++++++++++++++++++++-------- 2 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 src/lotus/lotusDeafen.test.ts diff --git a/src/lotus/lotusDeafen.test.ts b/src/lotus/lotusDeafen.test.ts new file mode 100644 index 00000000..b9a11ee6 --- /dev/null +++ b/src/lotus/lotusDeafen.test.ts @@ -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 { EventEmitter } from "events"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { of } from "rxjs"; +import { Track } from "livekit-client"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { setAudioEnabled$ } from "../controls"; +import { startLotusDeafen } from "./lotusDeafen"; +import { LotusWidgetActions } from "./lotusActions"; + +const lazyActions = new EventEmitter(); + +vi.mock("../widget", () => ({ + widget: { + api: { transport: { reply: vi.fn().mockResolvedValue(undefined) } }, + // Getter: `vi.mock` factories run at import time, before the const above. + get lazyActions(): EventEmitter { + return lazyActions; + }, + }, +})); + +/** Minimal CallViewModel stub: no connections, so no livekit rooms. */ +function mockVm(participants: unknown[] = []): CallViewModel { + const livekitRoom = { + remoteParticipants: new Map(participants.map((p, i) => [String(i), p])), + on: vi.fn(), + off: vi.fn(), + }; + return { + allConnections$: of({ getConnections: () => [{ livekitRoom }] }), + } as unknown as CallViewModel; +} + +function send(data: unknown): void { + lazyActions.emit(LotusWidgetActions.SetDeafen, { + detail: { data }, + }); +} + +/** Records everything pushed to the global audio-output subject. */ +let emissions: boolean[]; +let sub: { unsubscribe: () => void }; + +beforeEach(() => { + emissions = []; + sub = setAudioEnabled$.subscribe((v) => emissions.push(v)); +}); + +afterEach(() => { + sub.unsubscribe(); + lazyActions.removeAllListeners(); +}); + +test("deafen mutes, and undeafen unmutes, EC's global audio output", () => { + const stop = startLotusDeafen(mockVm()); + + send({ deafened: true, screenshareAudioMuted: false }); + expect(emissions).toEqual([false]); + + send({ deafened: false, screenshareAudioMuted: false }); + expect(emissions).toEqual([false, true]); + + stop(); + expect(emissions).toEqual([false, true]); +}); + +test("re-sending the same state is idempotent (host resendForkState)", () => { + const stop = startLotusDeafen(mockVm()); + + send({ deafened: true, screenshareAudioMuted: false }); + send({ deafened: true, screenshareAudioMuted: false }); + send({ deafened: true, screenshareAudioMuted: false }); + expect(emissions).toEqual([false]); + + stop(); + // Teardown restores the user's own state, which was "audio enabled". + expect(emissions).toEqual([false, true]); +}); + +test("undeafen does not re-enable audio the user had muted themselves", () => { + const stop = startLotusDeafen(mockVm()); + + // The user mutes all audio through EC's own control first. + setAudioEnabled$.next(false); + expect(emissions).toEqual([false]); + + send({ deafened: true, screenshareAudioMuted: false }); + send({ deafened: false, screenshareAudioMuted: false }); + // Nothing further was pushed: audio was already off and stays off. + expect(emissions).toEqual([false]); + + stop(); + expect(emissions).toEqual([false]); +}); + +test("deafen never touches per-participant volume", () => { + const participant = { setVolume: vi.fn() }; + const stop = startLotusDeafen(mockVm([participant])); + + send({ deafened: true, screenshareAudioMuted: false }); + send({ deafened: false, screenshareAudioMuted: false }); + expect(participant.setVolume).not.toHaveBeenCalled(); + + stop(); +}); + +test("screenshare-audio mute is applied per source and only undone for participants we muted", () => { + const participant = { setVolume: vi.fn() }; + const stop = startLotusDeafen(mockVm([participant])); + + // Not muted yet: no volume writes at all. + send({ deafened: false, screenshareAudioMuted: false }); + expect(participant.setVolume).not.toHaveBeenCalled(); + + send({ deafened: false, screenshareAudioMuted: true }); + expect(participant.setVolume).toHaveBeenCalledWith( + 0, + Track.Source.ScreenShareAudio, + ); + + participant.setVolume.mockClear(); + send({ deafened: false, screenshareAudioMuted: false }); + expect(participant.setVolume).toHaveBeenCalledWith( + 1, + Track.Source.ScreenShareAudio, + ); + + // Releasing again writes nothing: we no longer own that participant. + participant.setVolume.mockClear(); + send({ deafened: false, screenshareAudioMuted: false }); + expect(participant.setVolume).not.toHaveBeenCalled(); + + stop(); +}); + +test("a partial payload only moves the flag it names", () => { + const stop = startLotusDeafen(mockVm()); + + send({ deafened: true }); + expect(emissions).toEqual([false]); + send({ screenshareAudioMuted: true }); + // Still deafened: no change to the global mute. + expect(emissions).toEqual([false]); + + stop(); +}); diff --git a/src/lotus/lotusDeafen.ts b/src/lotus/lotusDeafen.ts index 2d0bc5b2..e5bec430 100644 --- a/src/lotus/lotusDeafen.ts +++ b/src/lotus/lotusDeafen.ts @@ -15,29 +15,52 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { setAudioEnabled$ } from "../controls"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; /** - * Handle the host's `io.lotus.set_deafen` toWidget action: silence remote audio - * at the LiveKit source, replacing cinny's brittle iframe-DOM `.muted` hack - * (which fought MatrixAudioRenderer and broke on re-render / late tracks). + * Handle the host's `io.lotus.set_deafen` toWidget action, replacing cinny's + * brittle iframe-DOM `.muted` hack (which fought MatrixAudioRenderer and broke + * on re-render / late tracks). * - * `deafened` mutes every remote participant's microphone AND screenshare audio; - * `screenshareAudioMuted` mutes only the screenshare audio (so the host can - * drop shared-tab/game audio while still hearing voices). Volume is set PER - * SOURCE via `RemoteParticipant.setVolume(volume, source)` — whose verified - * signature in livekit-client ^2.18.1 is - * `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)` - * and DEFAULTS `source` to `Microphone` (NOT "all audio"), so each source must - * be set explicitly. setVolume records the value in the participant's - * `volumeMap`, so a track that (re)subscribes later re-applies it automatically. + * `deafened` drives EC's OWN global audio output mute: `setAudioEnabled$` feeds + * `muteAllAudio$` (`src/state/MuteAllAudioModel.ts`), which `InCallView` already + * passes as `muted` to every `LivekitRoomAudioRenderer` (and hence + * `MatrixAudioRenderer`), plus `CallEventAudioRenderer` and + * `ReactionsAudioRenderer`. That silences EVERY remote source — microphone, + * screenshare audio and `Track.Source.Unknown` soundboard clips — and needs no + * per-participant bookkeeping for late joiners or reconnects. * - * State is closure-scoped (per invocation, matching the sibling lotus modules) - * and re-applied to every current room's participants on change, and to LATE - * JOINERS via `RoomEvent.ParticipantConnected`. The cinny host re-sends the - * current state on every call join (CallControl.forceState), so a fresh call - * never inherits a previous call's deafen state. + * This deliberately does NOT use `RemoteParticipant.setVolume` for deafen any + * more: EC's per-participant volume slider / per-tile mute writes the same + * `volumeMap` from `createVolumeControls` (`src/state/VolumeControls.ts`) + * whenever its `sink$` re-emits (every join, every participant re-resolution), + * so a `setVolume(0)` deafen was silently undone for anyone joining while + * deafened, and an undeafen `setVolume(1)` clobbered the user's own per-tile + * volume/mute state. + * + * `screenshareAudioMuted` is a NARROWER, independent host control (drop shared + * tab/game audio while still hearing voices). EC has no global per-source mute, + * so that one still has to go through + * `RemoteParticipant.setVolume(volume, Track.Source.ScreenShareAudio)` — whose + * verified signature in livekit-client ^2.18.1 is + * `setVolume(volume, source?: Track.Source.Microphone | Track.Source.ScreenShareAudio)`. + * It is re-applied to late joiners via `RoomEvent.ParticipantConnected`, and we + * only ever restore participants we muted ourselves. Known limitation: EC's own + * screenshare volume slider writes the same `volumeMap`, so a user who moves + * that slider while screenshare audio is host-muted wins; the mic path (the + * actual deafen) is no longer affected by that race at all. + * + * Undeafen restores the user's OWN output-enabled state as it was before the + * deafen (and never touches the `mute-all-audio` setting), so a user who had + * already muted all audio themselves stays muted. + * + * State is closure-scoped (per invocation, matching the sibling lotus modules). + * Applying the same state twice is a no-op, so the host's + * `CallControl.resendForkState()` after a reconnect is safe. The host re-sends + * the current state on every call join (CallControl.forceState), so a fresh + * call never inherits a previous call's deafen state. * * No effect unless the host sends the action. Returns a teardown function. */ @@ -48,32 +71,69 @@ export function startLotusDeafen(vm: CallViewModel): () => void { let deafened = false; let screenshareAudioMuted = false; + // The user's own audio-output state, tracked from `setAudioEnabled$` so an + // undeafen restores it rather than blindly enabling output. Defaults to + // `true`, matching `muteAllAudio$`'s `startWith(true)`. + let userAudioEnabled = true; + // Ignore our own (synchronous) emissions while tracking the user's state, and + // skip pushes that would not change anything so re-applying the same state + // (the host's `resendForkState()` after a reconnect) is a no-op. + let selfEmitting = false; + let lastApplied: boolean | null = null; + + const applyGlobalMute = (): void => { + const desired = deafened ? false : userAudioEnabled; + // `lastApplied` is our own override, if any; otherwise the live state is + // whatever the user last set. + const current = lastApplied ?? userAudioEnabled; + lastApplied = desired; + if (desired === current) return; + selfEmitting = true; + try { + setAudioEnabled$.next(desired); + } finally { + selfEmitting = false; + } + }; + + const audioSub = setAudioEnabled$.subscribe((enabled) => { + if (selfEmitting) return; + userAudioEnabled = enabled; + if (deafened && enabled) { + // Something else (the native output controls) re-enabled audio while + // deafened — re-assert the mute instead of silently losing deafen. + lastApplied = true; + applyGlobalMute(); + } + }); + + // Participants whose screenshare audio WE turned down, so undoing the host's + // screenshare mute never writes a volume to anyone else. + const screenshareMuted = new Set(); + const applyToParticipant = (p: RemoteParticipant): void => { - p.setVolume(deafened ? 0 : 1, Track.Source.Microphone); - p.setVolume( - deafened || screenshareAudioMuted ? 0 : 1, - Track.Source.ScreenShareAudio, - ); - // NOTE: injected/soundboard audio (published as `Track.Source.Unknown`) is - // deliberately NOT silenced here. The verified `setVolume` type signature - // only accepts `Track.Source.Microphone | Track.Source.ScreenShareAudio`, - // so passing `Unknown` would fail the fork's `tsc` gate and require an - // unsafe cast. Soundboard clips are short, host-triggered content the host - // already controls at the inject source, so leaving them audible is the - // type-clean, safe choice (a full-parity "mute everything" is not needed - // for the deafen semantics: don't-hear-other-people's-voices). + if (screenshareAudioMuted) { + p.setVolume(0, Track.Source.ScreenShareAudio); + screenshareMuted.add(p); + } else if (screenshareMuted.delete(p)) { + p.setVolume(1, Track.Source.ScreenShareAudio); + } }; const applyToRoom = (room: LivekitRoom): void => room.remoteParticipants.forEach(applyToParticipant); // Per-room ParticipantConnected listeners, so LATE JOINERS pick up the - // current deafen state the moment they connect. + // current screenshare-audio mute the moment they connect. Drive off the + // local participant's connection(s), not `livekitRoomItems$` — that stream is + // empty until a matrix-validated REMOTE member resolves, so listeners would + // be attached too late for the first joiner (the sibling lotus modules all + // use `allConnections$` for the same reason). const roomListeners = new Map void>(); let rooms: LivekitRoom[] = []; - const sub = vm.livekitRoomItems$.subscribe((items) => { - const next = items.map((i) => i.livekitRoom); + const sub = vm.allConnections$.subscribe((data) => { + const next = data.getConnections().map((c) => c.livekitRoom); rooms = next; // Detach listeners for rooms that went away. for (const [room, off] of roomListeners) { @@ -107,6 +167,7 @@ export function startLotusDeafen(vm: CallViewModel): () => void { logger.debug( `[lotus] set_deafen: deafened=${deafened} screenshareAudioMuted=${screenshareAudioMuted}`, ); + applyGlobalMute(); rooms.forEach(applyToRoom); }; @@ -115,6 +176,13 @@ export function startLotusDeafen(vm: CallViewModel): () => void { sub.unsubscribe(); for (const off of roomListeners.values()) off(); roomListeners.clear(); + // Leave the user's own output state as they had it before deafen. + if (deafened) { + deafened = false; + applyGlobalMute(); + } + screenshareMuted.clear(); + audioSub.unsubscribe(); w.lazyActions.off(LotusWidgetActions.SetDeafen, handler); }; }