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();
|
||||
});
|
||||
Reference in New Issue
Block a user