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,156 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
forceRelayOnlyIce,
|
||||
getRelayIceConfigs,
|
||||
seedTurnOnlyIceServers,
|
||||
waitForRelayedCandidatePairs,
|
||||
type TurnCredentials
|
||||
} from '../../helpers/turn-relay';
|
||||
import {
|
||||
isDockerAvailable,
|
||||
startTurnServer,
|
||||
type TurnServerHandle
|
||||
} from '../../helpers/turn-server';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
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';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* Symmetric NAT gives a browser no usable direct path, so the whole call has to
|
||||
* ride a TURN relay. `iceTransportPolicy: 'relay'` reproduces that without any
|
||||
* network trickery: host and server-reflexive candidates are thrown away, and
|
||||
* only the TURN server the app was configured with is left.
|
||||
*/
|
||||
test.describe('Relay-only voice', () => {
|
||||
let turnServer: TurnServerHandle | null = null;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (!await isDockerAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnServer = await startTurnServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await turnServer?.stop();
|
||||
turnServer = null;
|
||||
});
|
||||
|
||||
test('two users hear each other with every direct path removed', async ({ createClient }) => {
|
||||
test.skip(!turnServer, 'Relay-only voice needs Docker to run a local coturn.');
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const turn = turnServer as TurnServerHandle;
|
||||
const clients = await createRelayOnlyVoicePair(createClient, turn, `Relay Only Voice ${Date.now()}`);
|
||||
|
||||
await test.step('Both ends settled on a TURN relay, not a direct path', async () => {
|
||||
for (const client of clients) {
|
||||
const pairs = await waitForRelayedCandidatePairs(client.page, 1, 60_000);
|
||||
|
||||
// A direct pair here would mean the policy leaked and the test proved nothing.
|
||||
for (const pair of pairs) {
|
||||
expect(pair.localCandidateType, 'a peer connection escaped the relay-only policy').toBe('relay');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Audio flows both ways through the relay', async () => {
|
||||
for (const client of clients) {
|
||||
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createRelayOnlyVoicePair(
|
||||
createClient: () => Promise<Client>,
|
||||
turn: TurnCredentials,
|
||||
serverName: string
|
||||
): Promise<Client[]> {
|
||||
const clients: Client[] = [];
|
||||
const credentials: { username: string; displayName: string }[] = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await forceRelayOnlyIce(client.page);
|
||||
await seedTurnOnlyIceServers(client.page, turn);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push(client);
|
||||
credentials.push({
|
||||
displayName: `Relay Voice ${index + 1}`,
|
||||
username: `relay_voice_${Date.now()}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Register both clients', async () => {
|
||||
for (const [index, client] of clients.entries()) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
credentials[index].username,
|
||||
credentials[index].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: 'Relay-only voice 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 [index, client] of clients.entries()) {
|
||||
try {
|
||||
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
} catch (error) {
|
||||
// No TURN server in the config looks exactly like a failed relay from the
|
||||
// outside, so show what the app actually handed to WebRTC.
|
||||
const configs = await getRelayIceConfigs(client.page);
|
||||
|
||||
console.log(`[relay client ${index + 1} ice configs] ${JSON.stringify(configs.slice(0, 3))}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return clients;
|
||||
}
|
||||
Reference in New Issue
Block a user