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:
@@ -0,0 +1,293 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||
import { openSettingsDetailPage } from '../../helpers/settings-modal';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
interface VoiceClient extends Client {
|
||||
displayName: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
test.describe('Live audio device change', () => {
|
||||
test('switching the microphone mid-call keeps both directions of audio alive', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const clients = await createVoicePair(createClient, `Mic Swap ${Date.now()}`);
|
||||
const [alice, bob] = clients;
|
||||
|
||||
await assertMeshAudio(clients, 'initial two-user voice');
|
||||
|
||||
const connectionsBefore = {
|
||||
alice: await countCreatedPeerConnections(alice.page),
|
||||
bob: await countCreatedPeerConnections(bob.page)
|
||||
};
|
||||
const sentTracksBefore = await readOutboundAudioTrackIds(alice.page);
|
||||
|
||||
expect(sentTracksBefore, 'Alice should be sending audio before the switch').toHaveLength(1);
|
||||
|
||||
await test.step('Alice picks a different microphone from voice settings', async () => {
|
||||
await openVoiceSettings(alice.page);
|
||||
|
||||
const alternateDeviceId = await readAlternateInputDeviceId(alice.page);
|
||||
|
||||
await startVoiceStateWatch(alice.page);
|
||||
await alice.page.getByTestId('voice-settings-input-device').selectOption(alternateDeviceId);
|
||||
|
||||
// The swap re-captures the microphone; give it a moment before reading senders.
|
||||
await expect
|
||||
.poll(async () => (await readOutboundAudioTrackIds(alice.page))[0], { timeout: 20_000 })
|
||||
.not.toBe(sentTracksBefore[0]);
|
||||
});
|
||||
|
||||
await test.step('The session was never interrupted', async () => {
|
||||
const drops = await stopVoiceStateWatch(alice.page);
|
||||
|
||||
expect(drops, 'Alice left and rejoined voice instead of swapping the track').toBe(0);
|
||||
|
||||
expect(
|
||||
await countCreatedPeerConnections(alice.page),
|
||||
'Alice rebuilt her peer connection to change microphone'
|
||||
).toBe(connectionsBefore.alice);
|
||||
|
||||
expect(
|
||||
await countCreatedPeerConnections(bob.page),
|
||||
'Bob rebuilt his peer connection because Alice changed microphone'
|
||||
).toBe(connectionsBefore.bob);
|
||||
});
|
||||
|
||||
await test.step('Audio still flows both ways on the new microphone', async () => {
|
||||
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||
await assertMeshAudio(clients, 'after microphone switch');
|
||||
});
|
||||
});
|
||||
|
||||
test('switching the speaker mid-call keeps remote audio playing', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const clients = await createVoicePair(createClient, `Speaker Swap ${Date.now()}`);
|
||||
const [alice] = clients;
|
||||
|
||||
await assertMeshAudio(clients, 'initial two-user voice');
|
||||
|
||||
await openVoiceSettings(alice.page);
|
||||
|
||||
const alternateDeviceId = await readAlternateOutputDeviceId(alice.page);
|
||||
|
||||
test.skip(alternateDeviceId === null, 'This browser exposes no audio output devices');
|
||||
|
||||
await startVoiceStateWatch(alice.page);
|
||||
await alice.page.getByTestId('voice-settings-output-device').selectOption(alternateDeviceId as string);
|
||||
|
||||
await expect
|
||||
.poll(async () => readPreferredOutputDeviceId(alice.page), { timeout: 20_000 })
|
||||
.toBe(alternateDeviceId === '' ? 'default' : alternateDeviceId);
|
||||
|
||||
expect(await stopVoiceStateWatch(alice.page), 'Changing the speaker dropped Alice out of voice').toBe(0);
|
||||
|
||||
await assertMeshAudio(clients, 'after speaker switch');
|
||||
});
|
||||
});
|
||||
|
||||
async function openVoiceSettings(page: Page): Promise<void> {
|
||||
await openSettingsDetailPage(page, 'voice');
|
||||
await expect(page.getByTestId('voice-settings-input-device')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** The picker value to switch to: any real device, else the system-default entry. */
|
||||
async function readAlternateInputDeviceId(page: Page): Promise<string> {
|
||||
const select = page.getByTestId('voice-settings-input-device');
|
||||
const currentValue = await select.inputValue();
|
||||
const values = await select.locator('option').evaluateAll(
|
||||
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||
);
|
||||
const alternate = values.find((value) => value !== currentValue);
|
||||
|
||||
if (alternate === undefined) {
|
||||
throw new Error(`The microphone picker only offers "${currentValue}", so no switch can be made`);
|
||||
}
|
||||
|
||||
return alternate;
|
||||
}
|
||||
|
||||
async function readAlternateOutputDeviceId(page: Page): Promise<string | null> {
|
||||
const select = page.getByTestId('voice-settings-output-device');
|
||||
const currentValue = await select.inputValue();
|
||||
const values = await select.locator('option').evaluateAll(
|
||||
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||
);
|
||||
|
||||
return values.find((value) => value !== currentValue) ?? null;
|
||||
}
|
||||
|
||||
/** The audio track ids this page is currently sending, one per peer connection. */
|
||||
async function readOutboundAudioTrackIds(page: Page): Promise<(string | null)[]> {
|
||||
return await page.evaluate(() => {
|
||||
const connections = (window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
||||
|
||||
return connections
|
||||
.filter((connection) => connection.connectionState === 'connected')
|
||||
.map((connection) => {
|
||||
const audioSender = connection
|
||||
.getSenders()
|
||||
.find((sender) => sender.track?.kind === 'audio');
|
||||
|
||||
return audioSender?.track?.id ?? null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readPreferredOutputDeviceId(page: Page): Promise<string | null> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
interface PlaybackShape { preferredOutputDeviceId?: string }
|
||||
|
||||
const host = document.querySelector('app-voice-settings');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playback = debugApi.getComponent(host)['voicePlayback'] as PlaybackShape | undefined;
|
||||
|
||||
return playback?.preferredOutputDeviceId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start counting moments where this client considered itself out of voice.
|
||||
* A device change that tears the session down and rebuilds it registers here,
|
||||
* even when the end state looks healthy again.
|
||||
*/
|
||||
async function startVoiceStateWatch(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
interface VoiceStateShape { isConnected?: boolean }
|
||||
interface UserShape { voiceState?: VoiceStateShape }
|
||||
|
||||
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||
|
||||
watchWindow.__voiceDrops = 0;
|
||||
watchWindow.__voiceWatch = window.setInterval(() => {
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||||
|
||||
if (currentUser?.voiceState?.isConnected === false) {
|
||||
watchWindow.__voiceDrops = (watchWindow.__voiceDrops ?? 0) + 1;
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopVoiceStateWatch(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||
|
||||
if (watchWindow.__voiceWatch !== undefined) {
|
||||
window.clearInterval(watchWindow.__voiceWatch);
|
||||
watchWindow.__voiceWatch = undefined;
|
||||
}
|
||||
|
||||
return watchWindow.__voiceDrops ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function createVoicePair(
|
||||
createClient: () => Promise<Client>,
|
||||
serverName: string
|
||||
): Promise<VoiceClient[]> {
|
||||
const clients: VoiceClient[] = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
displayName: `Device Voice ${index + 1}`,
|
||||
username: `device_voice_${Date.now()}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Register both clients', async () => {
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create and join the server', async () => {
|
||||
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||
description: 'Live audio device change test'
|
||||
});
|
||||
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Join both clients to voice', async () => {
|
||||
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user