fix(voice): route media on evidence and switch devices without dropping the call
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.
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Owns the audio-device selection for voice: which microphone and speaker the
|
||||
* user picked, persisting it, and applying it to a live session.
|
||||
*
|
||||
* Both the in-channel voice controls and the settings modal go through this
|
||||
* service, so a picker cannot be a dead control in one surface and live in the
|
||||
* other, and a device change never costs the user their voice session.
|
||||
*/
|
||||
import {
|
||||
Injectable,
|
||||
OnDestroy,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { SYSTEM_DEFAULT_AUDIO_DEVICE_ID, resolveAudioDeviceSelection } from '../../domain/logic/audio-device-selection.rules';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
export interface AudioDeviceOption {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Translation keys shown when a device disappears underneath the user. */
|
||||
export const INPUT_FALLBACK_NOTICE = 'voice.devices.inputFellBack';
|
||||
export const OUTPUT_FALLBACK_NOTICE = 'voice.devices.outputFellBack';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceAudioDeviceService implements OnDestroy {
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
|
||||
private readonly _inputDevices = signal<AudioDeviceOption[]>([]);
|
||||
private readonly _outputDevices = signal<AudioDeviceOption[]>([]);
|
||||
private readonly _selectedInputDeviceId = signal(SYSTEM_DEFAULT_AUDIO_DEVICE_ID);
|
||||
private readonly _selectedOutputDeviceId = signal(SYSTEM_DEFAULT_AUDIO_DEVICE_ID);
|
||||
private readonly _deviceNotice = signal<string | null>(null);
|
||||
|
||||
readonly inputDevices = computed(() => this._inputDevices());
|
||||
readonly outputDevices = computed(() => this._outputDevices());
|
||||
readonly selectedInputDeviceId = computed(() => this._selectedInputDeviceId());
|
||||
readonly selectedOutputDeviceId = computed(() => this._selectedOutputDeviceId());
|
||||
/** A translation key when a selected device vanished, otherwise `null`. */
|
||||
readonly deviceNotice = computed(() => this._deviceNotice());
|
||||
|
||||
private readonly handleDeviceChange = (): void => {
|
||||
void this.refreshDevices({ reconcileSelection: true });
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this._selectedInputDeviceId.set(settings.inputDevice);
|
||||
this._selectedOutputDeviceId.set(settings.outputDevice);
|
||||
|
||||
navigator.mediaDevices?.addEventListener?.('devicechange', this.handleDeviceChange);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);
|
||||
}
|
||||
|
||||
/** Re-read the browser device lists, optionally repairing a stale selection. */
|
||||
async refreshDevices(options: { reconcileSelection?: boolean } = {}): Promise<void> {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
let devices: MediaDeviceInfo[];
|
||||
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
this._inputDevices.set(toDeviceOptions(devices, 'audioinput'));
|
||||
this._outputDevices.set(toDeviceOptions(devices, 'audiooutput'));
|
||||
|
||||
if (options.reconcileSelection) {
|
||||
await this.reconcileSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick a microphone, applying it to a live session when there is one. */
|
||||
async selectInputDevice(deviceId: string): Promise<void> {
|
||||
this._deviceNotice.set(null);
|
||||
await this.applyInputDevice(deviceId);
|
||||
}
|
||||
|
||||
/** Pick a speaker, applying it to the live playback pipelines. */
|
||||
async selectOutputDevice(deviceId: string): Promise<void> {
|
||||
this._deviceNotice.set(null);
|
||||
this.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
/** Re-apply the stored speaker choice to the live playback pipelines. */
|
||||
applySelectedOutputDevice(): void {
|
||||
this.voicePlayback.applyOutputDevice(this._selectedOutputDeviceId());
|
||||
}
|
||||
|
||||
dismissDeviceNotice(): void {
|
||||
this._deviceNotice.set(null);
|
||||
}
|
||||
|
||||
private async applyInputDevice(deviceId: string): Promise<void> {
|
||||
this._selectedInputDeviceId.set(deviceId);
|
||||
saveVoiceSettingsToStorage({ inputDevice: deviceId });
|
||||
|
||||
if (!this.voiceConnection.isVoiceConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await this.voiceConnection.switchInputDevice(deviceId);
|
||||
|
||||
if (stream) {
|
||||
this.voiceActivity.refreshLocalMicStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private applyOutputDevice(deviceId: string): void {
|
||||
this._selectedOutputDeviceId.set(deviceId);
|
||||
saveVoiceSettingsToStorage({ outputDevice: deviceId });
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair selections whose device is gone. An empty device list is treated as
|
||||
* missing evidence, not as an unplugged device.
|
||||
*/
|
||||
private async reconcileSelection(): Promise<void> {
|
||||
const input = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: this._selectedInputDeviceId(),
|
||||
availableDeviceIds: this._inputDevices().map((device) => device.deviceId)
|
||||
});
|
||||
const output = resolveAudioDeviceSelection({
|
||||
selectedDeviceId: this._selectedOutputDeviceId(),
|
||||
availableDeviceIds: this._outputDevices().map((device) => device.deviceId)
|
||||
});
|
||||
|
||||
if (input.didFallBack) {
|
||||
await this.applyInputDevice(input.deviceId);
|
||||
this._deviceNotice.set(INPUT_FALLBACK_NOTICE);
|
||||
}
|
||||
|
||||
if (output.didFallBack) {
|
||||
this.applyOutputDevice(output.deviceId);
|
||||
this._deviceNotice.set(OUTPUT_FALLBACK_NOTICE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices without an id cannot be requested, and the empty id already means
|
||||
* "system default" in the picker, so they are dropped.
|
||||
*/
|
||||
function toDeviceOptions(devices: MediaDeviceInfo[], kind: MediaDeviceKind): AudioDeviceOption[] {
|
||||
return devices
|
||||
.filter((device) => device.kind === kind && !!device.deviceId)
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }));
|
||||
}
|
||||
Reference in New Issue
Block a user