import { Injector, runInInjectionContext, signal, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core'; import { Store } from '@ngrx/store'; import { Subject } from 'rxjs'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { ScreenShareFacade } from '../../../screen-share'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { VoicePlaybackService } from './voice-playback.service'; let audioContextCount = 0; let createdGainNodes: { gain: { value: number } }[] = []; describe('VoicePlaybackService', () => { beforeEach(() => { audioContextCount = 0; createdGainNodes = []; installAudioDomMocks(); installLocalStorageMock(); }); it('creates one audio pipeline per peer on first connect', () => { const context = createServiceContext({ isVoiceConnected: true }); const stream = createMockAudioStream(['track-a']); context.service.handleRemoteStream('peer-1', stream, connectedOptions()); expect(audioContextCount).toBe(1); }); it('reuses the existing pipeline when the same voice stream is handled again', () => { const context = createServiceContext({ isVoiceConnected: true }); const stream = createMockAudioStream(['track-a']); context.service.handleRemoteStream('peer-1', stream, connectedOptions()); context.service.handleRemoteStream('peer-1', stream, connectedOptions()); expect(audioContextCount).toBe(1); }); it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => { const context = createServiceContext({ isVoiceConnected: true }); const track = createMockAudioTrack('track-a'); const firstStream = createMockAudioStreamFromTracks([track]); const secondStream = createMockAudioStreamFromTracks([track]); context.service.handleRemoteStream('peer-1', firstStream, connectedOptions()); context.service.handleRemoteStream('peer-1', secondStream, connectedOptions()); expect(audioContextCount).toBe(1); }); it('rebuilds the pipeline when the live audio track set changes', () => { const context = createServiceContext({ isVoiceConnected: true }); const firstStream = createMockAudioStream(['track-a']); const secondStream = createMockAudioStream(['track-b']); context.service.handleRemoteStream('peer-1', firstStream, connectedOptions()); context.service.handleRemoteStream('peer-1', secondStream, connectedOptions()); expect(audioContextCount).toBe(2); }); it('does not recreate pipelines for unrelated remote stream notifications', () => { const context = createServiceContext({ isVoiceConnected: true }); const voiceStream = createMockAudioStream(['track-a']); context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream); context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions()); context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) }); expect(audioContextCount).toBe(1); }); it('creates pipelines for each peer when multiple friends join voice', () => { const context = createServiceContext({ isVoiceConnected: true }); context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions()); context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions()); expect(audioContextCount).toBe(2); }); it('still applies updated playback options when reusing an existing pipeline', () => { const context = createServiceContext({ isVoiceConnected: true }); const stream = createMockAudioStream(['track-a']); context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 })); context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true })); expect(audioContextCount).toBe(1); expect(context.service.getUserVolume('peer-1')).toBe(100); }); // The roster is gossip. A peer we already receive audio from stays audible unless the // voice session has positive evidence it left, otherwise a dropped socket silences a // member who never went anywhere. it('keeps a peer audible while the voice session holds the path open', () => { const context = createServiceContext({ isVoiceConnected: true }); context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions()); expect(context.voiceConnection.mayHearPeerVoice).toHaveBeenCalledWith('peer-1', true); expect(createdGainNodes.at(-1)?.gain.value).toBe(1); }); it('mutes a peer the voice session reports as gone from our channel', () => { const context = createServiceContext({ isVoiceConnected: true }); context.voiceConnection.mayHearPeerVoice.mockReturnValue(false); context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions()); expect(createdGainNodes.at(-1)?.gain.value).toBe(0); }); }); interface ServiceContext { service: VoicePlaybackService; voiceConnection: { isVoiceConnected: ReturnType>; onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>; onVoiceConnected: Subject; onPeerDisconnected: Subject; getRemoteVoiceStream: ReturnType; getConnectedPeers: ReturnType; syncOutgoingVoiceRouting: ReturnType; mayHearPeerVoice: ReturnType; }; remoteStream$: Subject<{ peerId: string; stream: MediaStream }>; } function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext { const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); const voiceConnection = { isVoiceConnected: signal(options.isVoiceConnected ?? false), onRemoteStream: remoteStream$, onVoiceConnected: new Subject(), onPeerDisconnected: new Subject(), getRemoteVoiceStream: vi.fn(() => null), getConnectedPeers: vi.fn(() => []), syncOutgoingVoiceRouting: vi.fn(), mayHearPeerVoice: vi.fn(() => true) }; const screenShare = { isScreenShareRemotePlaybackSuppressed: signal(false), forceDefaultRemotePlaybackOutput: signal(false) }; const store = { selectSignal: vi.fn((selector: unknown) => { if (selector === selectCurrentUser) { return signal(null); } if (selector === selectAllUsers) { return signal([]); } throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`); }) }; const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>(); const effectScheduler = { add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { scheduledEffects.add(scheduledEffect); }), flush: vi.fn(() => { for (const scheduledEffect of scheduledEffects) { if (scheduledEffect.dirty) { scheduledEffect.run(); } } }), remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { scheduledEffects.delete(scheduledEffect); }), schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { scheduledEffects.add(scheduledEffect); }) }; const injector = Injector.create({ providers: [ VoicePlaybackService, { provide: ChangeDetectionScheduler, useValue: { notify: vi.fn() } }, { provide: EffectScheduler, useValue: effectScheduler }, { provide: VoiceConnectionFacade, useValue: voiceConnection }, { provide: ScreenShareFacade, useValue: screenShare }, { provide: Store, useValue: store } ] }); return { service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)), voiceConnection, remoteStream$ }; } function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) { return { isConnected: true, outputVolume: overrides.outputVolume ?? 1, isDeafened: overrides.isDeafened ?? false }; } function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack { return { id, kind: 'audio', readyState, enabled: true } as MediaStreamTrack; } function createMockAudioStream(trackIds: string[]): MediaStream { return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id))); } function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream { return { getAudioTracks: () => tracks, getTracks: () => tracks } as unknown as MediaStream; } function installAudioDomMocks(): void { vi.stubGlobal('MediaStream', class MediaStream { constructor(private readonly tracks: MediaStreamTrack[] = []) {} getAudioTracks(): MediaStreamTrack[] { return this.tracks; } getTracks(): MediaStreamTrack[] { return this.tracks; } }); vi.stubGlobal('Audio', class { muted = false; volume = 1; srcObject: MediaStream | null = null; play = vi.fn().mockResolvedValue(undefined); remove = vi.fn(); }); vi.stubGlobal('AudioContext', class { state = 'running'; close = vi.fn().mockResolvedValue(undefined); constructor() { audioContextCount += 1; } createGain() { const gainNode = { gain: { value: 0 }, connect: vi.fn() }; createdGainNodes.push(gainNode); return gainNode; } createMediaStreamSource() { return { connect: vi.fn() }; } createMediaStreamDestination() { return { stream: createMockAudioStream(['destination']) }; } }); } function installLocalStorageMock(): void { const storage = new Map(); vi.stubGlobal('localStorage', { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { storage.set(key, value); }, removeItem: (key: string) => { storage.delete(key); }, clear: () => { storage.clear(); } }); }