Outgoing voice was gated on the observer's roster copy of the remote user's voice state, which is signaling gossip. The signal server broadcasts `user_left` for any socket it declares dead, so a suspended laptop or a flaky hop wiped that copy and the observer detached its microphone from a peer that never left the channel - a silent member with no way back through the UI. `decideVoicePathRouting` now closes a path only on positive evidence: we left voice, the peer itself reported another channel or none, or the connection is gone. Missing gossip holds an established path instead. Opening still needs confirmation, so a guess never starts sending; the same rule gates playback, camera video, and the microphone a new connection puts in its first offer. Peers are also asked for their voice state when a connection or data channel comes up, so a rebuilt path re-confirms itself. Alongside it, the microphone can be switched mid-call: capture moves to a device service and rules, the live track is swapped with `replaceTrack` so the session is never renegotiated, and the speaking indicator follows the new stream.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
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<typeof signal<boolean>>;
|
|
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
|
|
onVoiceConnected: Subject<void>;
|
|
onPeerDisconnected: Subject<string>;
|
|
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
|
getConnectedPeers: ReturnType<typeof vi.fn>;
|
|
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
|
mayHearPeerVoice: ReturnType<typeof vi.fn>;
|
|
};
|
|
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<void>(),
|
|
onPeerDisconnected: new Subject<string>(),
|
|
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<string, string>();
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|