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.
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|