diff --git a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts index c364bde8..78294086 100644 --- a/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts +++ b/apps/desktop/src/renderer/hooks/useWebRTCViewerSFUAPI.test.ts @@ -39,6 +39,52 @@ vi.mock('@/lib/ipc', () => ({ }), })); +// Remote audio now plays through one element per participant, each fronted by a +// Web Audio gain stage. jsdom has no AudioContext, so stand in for the graph and +// record which tracks were wired up. +interface AmplifiedStub { + track: { id?: string }; + disposed: boolean; + setGain: ReturnType; + dispose: () => void; +} +const amplified: AmplifiedStub[] = []; +vi.mock('@/lib/remoteAudioGain', () => ({ + amplifyRemoteAudio: (track: { id?: string }) => { + const entry: AmplifiedStub = { + track, + disposed: false, + setGain: vi.fn(), + dispose: () => { + entry.disposed = true; + }, + }; + (entry as unknown as { stream: unknown }).stream = new MockMediaStream([track]); + amplified.push(entry); + return entry; + }, +})); + +/** The audio elements the hook created, in creation order. */ +const audioElements: { muted: boolean; paused: boolean; srcObject: unknown }[] = []; +class MockAudio { + muted = false; + paused = false; + autoplay = false; + volume = 1; + srcObject: unknown = null; + constructor() { + audioElements.push(this); + } + play() { + return Promise.resolve(); + } + pause() { + this.paused = true; + } +} +(globalThis as Record).Audio = MockAudio; + // Mock LiveKit Room const mockPublishData = vi.fn(); const mockSetMicrophoneEnabled = vi.fn().mockResolvedValue(undefined); @@ -118,6 +164,8 @@ describe('useWebRTCViewerSFUAPI', () => { beforeEach(() => { vi.clearAllMocks(); mockRemoteParticipants.clear(); + amplified.length = 0; + audioElements.length = 0; // Re-set mock implementations (vi.restoreAllMocks clears them) mockFetch.mockResolvedValue({ @@ -270,7 +318,7 @@ describe('useWebRTCViewerSFUAPI', () => { expect(hookResult!.current.remoteStream).not.toBeNull(); }); - it('should merge subscribed audio and video tracks into one remote stream', async () => { + it('should keep remote audio out of the remote stream and play it per participant', async () => { let hookResult: { current: ReturnType }; await act(async () => { @@ -282,44 +330,87 @@ describe('useWebRTCViewerSFUAPI', () => { await Promise.resolve(); }); - const mockAudioTrack = { - kind: 'audio', - mediaStreamTrack: { id: 'audio-1', kind: 'audio' }, - }; - const mockVideoTrack = { - kind: 'video', - mediaStreamTrack: { id: 'video-1', kind: 'video' }, - }; + const hostAudio = { kind: 'audio', mediaStreamTrack: { id: 'audio-host', kind: 'audio' } }; + const peerAudio = { kind: 'audio', mediaStreamTrack: { id: 'audio-peer', kind: 'audio' } }; + const mockVideoTrack = { kind: 'video', mediaStreamTrack: { id: 'video-1', kind: 'video' } }; + // A media element plays only the FIRST audio track of the stream it is + // given, so folding both of these into remoteStream made whichever arrived + // second inaudible for the whole session. Each one gets its own element. act(() => { - mockRoomInstance.emit('trackSubscribed', mockAudioTrack, {}, { identity: 'host-1' }); + mockRoomInstance.emit('trackSubscribed', hostAudio, {}, { identity: 'host-1' }); + mockRoomInstance.emit('trackSubscribed', peerAudio, {}, { identity: 'viewer-2' }); }); - const firstStream = hookResult!.current.remoteStream; - expect(firstStream).not.toBeNull(); - expect(firstStream?.getAudioTracks()).toHaveLength(1); - expect(firstStream?.getVideoTracks()).toHaveLength(0); + expect(hookResult!.current.remoteStream).toBeNull(); + expect(audioElements).toHaveLength(2); + expect(amplified.map((a) => a.track.id)).toEqual(['audio-host', 'audio-peer']); act(() => { mockRoomInstance.emit('trackSubscribed', mockVideoTrack, {}, { identity: 'host-1' }); }); - const mergedStream = hookResult!.current.remoteStream; - // A NEW reference, not a mutated one: React bails on setState with the same - // object, so VideoViewer's srcObject effect would never re-bind. - expect(mergedStream).not.toBe(firstStream); - expect(mergedStream?.getAudioTracks()).toHaveLength(1); - expect(mergedStream?.getVideoTracks()).toHaveLength(1); + // The stream carries video and nothing else. + const videoStream = hookResult!.current.remoteStream; + expect(videoStream?.getVideoTracks()).toHaveLength(1); + expect(videoStream?.getAudioTracks()).toHaveLength(0); + // One participant leaving tears down only their own playback. act(() => { - mockRoomInstance.emit('trackUnsubscribed', mockAudioTrack); + mockRoomInstance.emit('trackUnsubscribed', peerAudio); }); - expect(hookResult!.current.remoteStream).not.toBe(mergedStream); - expect(hookResult!.current.remoteStream?.getAudioTracks()).toHaveLength(0); + expect(amplified.find((a) => a.track.id === 'audio-peer')?.disposed).toBe(true); + expect(amplified.find((a) => a.track.id === 'audio-host')?.disposed).toBe(false); expect(hookResult!.current.remoteStream?.getVideoTracks()).toHaveLength(1); }); + it('should mute every remote participant through setSpeakerMuted', async () => { + let hookResult: { current: ReturnType }; + + await act(async () => { + const { result } = renderHook(() => useWebRTCViewerSFUAPI(defaultOptions)); + hookResult = result; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => { + mockRoomInstance.emit( + 'trackSubscribed', + { kind: 'audio', mediaStreamTrack: { id: 'audio-host', kind: 'audio' } }, + {}, + { identity: 'host-1' } + ); + }); + + // Remote audio no longer lives in the