fix(lotus): implement deafen via EC's global output mute, not setVolume
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5
parent
e36aef8aa3
commit
f1cfcc7377
@@ -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();
|
||||
});
|
||||
+101
-33
@@ -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<RemoteParticipant>();
|
||||
|
||||
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<LivekitRoom, () => 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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user