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:
2026-08-14 03:19:29 +02:00
parent a83f5aa750
commit 92c2f578e2
33 changed files with 3149 additions and 317 deletions
@@ -0,0 +1,151 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { countCreatedPeerConnections } from '../../helpers/peer-role';
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
getOpenDataChannelCount,
getPerPeerAudioStats
} from '../../helpers/webrtc-helpers';
type PeerAudioStats = Awaited<ReturnType<typeof getPerPeerAudioStats>>;
/** Override for a quick check or a longer leak hunt: `SOAK_MINUTES=2 npx playwright test ...`. */
const SOAK_MINUTES = Number(process.env['SOAK_MINUTES'] ?? 30);
const SAMPLE_INTERVAL_MS = 30_000;
interface ResourceSnapshot {
audioElements: number;
heapMb: number;
remoteTracks: number;
}
/**
* A long call must not accumulate anything. Structural counters are the honest leak
* signal here - a churning recovery loop shows up as extra remote tracks or audio
* elements long before heap bytes say anything conclusive.
*/
async function readResources(page: Page): Promise<ResourceSnapshot> {
return page.evaluate(() => {
interface HeapCapablePerformance extends Performance {
memory?: { usedJSHeapSize: number };
}
const usedHeap = (performance as HeapCapablePerformance).memory?.usedJSHeapSize ?? 0;
const remoteTracks = (window as unknown as { __rtcRemoteTracks?: unknown[] }).__rtcRemoteTracks ?? [];
return {
audioElements: document.querySelectorAll('audio').length,
heapMb: Math.round(usedHeap / (1_024 * 1_024)),
remoteTracks: remoteTracks.length
};
});
}
function describeStats(stats: PeerAudioStats): string {
return stats
.map((stat) => `${stat.connectionState} in=${stat.inboundPackets} out=${stat.outboundPackets}`)
.join(' | ') || 'no peers';
}
test.describe('Long voice session', () => {
test(`carries audio for ${SOAK_MINUTES} minutes without stalling, rebuilding, or accumulating`, async ({
createClient
}) => {
const soakMs = SOAK_MINUTES * 60_000;
test.setTimeout(soakMs + 300_000);
const clients = await createVoicePairInNewServer(createClient, `Voice Soak ${Date.now()}`, {
namePrefix: 'Soak Voice'
});
const baselineConnections = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
expect(baselineConnections, 'each client should start with exactly one peer connection').toEqual([1, 1]);
const baselineResources = await Promise.all(clients.map((client) => readResources(client.page)));
const previousStats: PeerAudioStats[] = await Promise.all(
clients.map((client) => getPerPeerAudioStats(client.page))
);
const deadline = Date.now() + soakMs;
const startedAt = Date.now();
let sampleIndex = 0;
while (Date.now() < deadline) {
await clients[0].page.waitForTimeout(SAMPLE_INTERVAL_MS);
sampleIndex++;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
for (let index = 0; index < clients.length; index++) {
const client = clients[index];
await assertClientStillHealthy(client, previousStats[index], elapsedSeconds);
previousStats[index] = await getPerPeerAudioStats(client.page);
}
const resources = await Promise.all(clients.map((current) => readResources(current.page)));
console.log(
`[soak] sample ${sampleIndex} at +${elapsedSeconds}s: `
+ clients
.map((client, index) => `${client.displayName} heap=${resources[index].heapMb}MB`
+ ` audio=${resources[index].audioElements} tracks=${resources[index].remoteTracks}`)
.join(', ')
);
}
await test.step('Nothing accumulated over the session', async () => {
const finalResources = await Promise.all(clients.map((client) => readResources(client.page)));
for (let index = 0; index < clients.length; index++) {
const baseline = baselineResources[index];
const final = finalResources[index];
const label = clients[index].displayName;
// A stable call fires `track` once per remote track; repeats mean the media path
// was torn down and rebuilt behind the assertions above.
expect(final.remoteTracks, `${label} gained remote tracks during the soak`).toBe(baseline.remoteTracks);
expect(final.audioElements, `${label} accumulated audio elements`).toBeLessThanOrEqual(baseline.audioElements + 1);
expect(
final.heapMb,
`${label} heap grew from ${baseline.heapMb}MB to ${final.heapMb}MB`
).toBeLessThan(baseline.heapMb * 3 + 200);
}
});
});
});
async function assertClientStillHealthy(
client: VoicePairClient,
previous: PeerAudioStats,
elapsedSeconds: number
): Promise<void> {
const label = `${client.displayName} at +${elapsedSeconds}s`;
try {
const current = await getPerPeerAudioStats(client.page);
const connected = current.filter((stat) => stat.connectionState === 'connected');
expect(connected, `${label}: expected exactly one connected peer, saw ${describeStats(current)}`).toHaveLength(1);
const before = previous[0];
const now = current[0];
expect(now.inboundPackets, `${label}: inbound audio stalled`).toBeGreaterThan(before.inboundPackets);
expect(now.outboundPackets, `${label}: outbound audio stalled`).toBeGreaterThan(before.outboundPackets);
// A rebuild would restore audio within a sample or two, so the flow assertions above
// cannot see it. Only the creation count can.
expect(
await countCreatedPeerConnections(client.page),
`${label}: the peer connection was rebuilt mid-call`
).toBe(1);
expect(await getOpenDataChannelCount(client.page), `${label}: the control channel is not open`).toBe(1);
} catch (error) {
console.log(`[soak] ${label} diagnostics:\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}