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.
199 lines
6.7 KiB
TypeScript
199 lines
6.7 KiB
TypeScript
import { Injector, runInInjectionContext } from '@angular/core';
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi
|
|
} from 'vitest';
|
|
import { VoiceAudioDeviceService } from './voice-audio-device.service';
|
|
import {
|
|
VoiceActivityService,
|
|
VoiceConnectionFacade,
|
|
VoicePlaybackService
|
|
} from '../../../voice-connection';
|
|
import { saveVoiceSettingsToStorage, loadVoiceSettingsFromStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
|
|
|
const BUILT_IN_MIC = { kind: 'audioinput', deviceId: 'default', label: 'Built-in Mic' };
|
|
const HEADSET_MIC = { kind: 'audioinput', deviceId: 'headset', label: 'Headset' };
|
|
const SPEAKERS = { kind: 'audiooutput', deviceId: 'speakers', label: 'Speakers' };
|
|
const HDMI_OUTPUT = { kind: 'audiooutput', deviceId: 'hdmi', label: 'HDMI Output' };
|
|
|
|
interface DeviceHarness {
|
|
service: VoiceAudioDeviceService;
|
|
voiceConnection: {
|
|
isVoiceConnected: ReturnType<typeof vi.fn>;
|
|
switchInputDevice: ReturnType<typeof vi.fn>;
|
|
};
|
|
voicePlayback: { applyOutputDevice: ReturnType<typeof vi.fn> };
|
|
voiceActivity: { refreshLocalMicStream: ReturnType<typeof vi.fn> };
|
|
emitDeviceChange: () => Promise<void>;
|
|
setDevices: (devices: { kind: string; deviceId: string; label: string }[]) => void;
|
|
}
|
|
|
|
describe('VoiceAudioDeviceService', () => {
|
|
let harness: DeviceHarness;
|
|
|
|
beforeEach(() => {
|
|
harness = createHarness();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('lists the microphones and speakers the browser reports', async () => {
|
|
await harness.service.refreshDevices();
|
|
|
|
expect(harness.service.inputDevices().map((device) => device.deviceId)).toEqual(['default', 'headset']);
|
|
expect(harness.service.outputDevices().map((device) => device.deviceId)).toEqual(['speakers']);
|
|
});
|
|
|
|
it('switches the live microphone instead of rejoining voice', async () => {
|
|
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
|
|
|
await harness.service.selectInputDevice('headset');
|
|
|
|
expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith('headset');
|
|
expect(harness.service.selectedInputDeviceId()).toBe('headset');
|
|
expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset');
|
|
});
|
|
|
|
it('keeps the local speaking indicator on the new microphone stream', async () => {
|
|
const replacementStream = { id: 'replacement' } as MediaStream;
|
|
|
|
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
|
harness.voiceConnection.switchInputDevice.mockResolvedValue(replacementStream);
|
|
|
|
await harness.service.selectInputDevice('headset');
|
|
|
|
expect(harness.voiceActivity.refreshLocalMicStream).toHaveBeenCalledWith(replacementStream);
|
|
});
|
|
|
|
it('applies a speaker choice to the live playback pipelines', async () => {
|
|
await harness.service.selectOutputDevice('speakers');
|
|
|
|
expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith('speakers');
|
|
expect(loadVoiceSettingsFromStorage().outputDevice).toBe('speakers');
|
|
});
|
|
|
|
it('falls back to the system default when the selected microphone is unplugged', async () => {
|
|
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
|
|
|
await harness.service.selectInputDevice('headset');
|
|
harness.voiceConnection.switchInputDevice.mockClear();
|
|
|
|
harness.setDevices([BUILT_IN_MIC, SPEAKERS]);
|
|
|
|
await harness.emitDeviceChange();
|
|
|
|
expect(harness.voiceConnection.switchInputDevice).toHaveBeenCalledWith('');
|
|
expect(harness.service.selectedInputDeviceId()).toBe('');
|
|
expect(harness.service.deviceNotice()).toBe('voice.devices.inputFellBack');
|
|
});
|
|
|
|
it('falls back to the system default when the selected speaker is unplugged', async () => {
|
|
await harness.service.selectOutputDevice('speakers');
|
|
harness.voicePlayback.applyOutputDevice.mockClear();
|
|
|
|
harness.setDevices([BUILT_IN_MIC, HDMI_OUTPUT]);
|
|
|
|
await harness.emitDeviceChange();
|
|
|
|
expect(harness.voicePlayback.applyOutputDevice).toHaveBeenCalledWith('');
|
|
expect(harness.service.deviceNotice()).toBe('voice.devices.outputFellBack');
|
|
});
|
|
|
|
it('does not report a speaker fallback when the browser lists no speakers at all', async () => {
|
|
await harness.service.selectOutputDevice('speakers');
|
|
harness.voicePlayback.applyOutputDevice.mockClear();
|
|
|
|
harness.setDevices([BUILT_IN_MIC]);
|
|
|
|
await harness.emitDeviceChange();
|
|
|
|
expect(harness.voicePlayback.applyOutputDevice).not.toHaveBeenCalled();
|
|
expect(harness.service.deviceNotice()).toBeNull();
|
|
});
|
|
|
|
it('leaves a selection alone when a device list arrives empty', async () => {
|
|
harness.voiceConnection.isVoiceConnected.mockReturnValue(true);
|
|
|
|
await harness.service.selectInputDevice('headset');
|
|
harness.voiceConnection.switchInputDevice.mockClear();
|
|
|
|
harness.setDevices([]);
|
|
|
|
await harness.emitDeviceChange();
|
|
|
|
expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled();
|
|
expect(harness.service.selectedInputDeviceId()).toBe('headset');
|
|
expect(harness.service.deviceNotice()).toBeNull();
|
|
});
|
|
|
|
it('records a device choice made before joining voice without capturing', async () => {
|
|
harness.voiceConnection.isVoiceConnected.mockReturnValue(false);
|
|
|
|
await harness.service.selectInputDevice('headset');
|
|
|
|
expect(harness.voiceConnection.switchInputDevice).not.toHaveBeenCalled();
|
|
expect(loadVoiceSettingsFromStorage().inputDevice).toBe('headset');
|
|
});
|
|
});
|
|
|
|
function createHarness(): DeviceHarness {
|
|
saveVoiceSettingsToStorage({ inputDevice: '', outputDevice: '' });
|
|
|
|
let devices = [
|
|
BUILT_IN_MIC,
|
|
HEADSET_MIC,
|
|
SPEAKERS
|
|
];
|
|
|
|
const listeners: (() => void)[] = [];
|
|
|
|
vi.stubGlobal('navigator', {
|
|
mediaDevices: {
|
|
enumerateDevices: vi.fn(async () => devices),
|
|
addEventListener: vi.fn((type: string, listener: () => void) => {
|
|
if (type === 'devicechange') {
|
|
listeners.push(listener);
|
|
}
|
|
}),
|
|
removeEventListener: vi.fn()
|
|
}
|
|
});
|
|
|
|
const voiceConnection = {
|
|
isVoiceConnected: vi.fn(() => false),
|
|
switchInputDevice: vi.fn(async () => null)
|
|
};
|
|
const voicePlayback = { applyOutputDevice: vi.fn() };
|
|
const voiceActivity = { refreshLocalMicStream: vi.fn() };
|
|
const injector = Injector.create({
|
|
providers: [
|
|
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
|
|
{ provide: VoicePlaybackService, useValue: voicePlayback },
|
|
{ provide: VoiceActivityService, useValue: voiceActivity }
|
|
]
|
|
});
|
|
const service = runInInjectionContext(injector, () => new VoiceAudioDeviceService());
|
|
|
|
return {
|
|
service,
|
|
voiceConnection,
|
|
voicePlayback,
|
|
voiceActivity,
|
|
emitDeviceChange: async () => {
|
|
listeners.forEach((listener) => listener());
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
},
|
|
setDevices: (next) => {
|
|
devices = next;
|
|
}
|
|
};
|
|
}
|