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,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;
}
}
}
@@ -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;
}
}
@@ -0,0 +1,252 @@
import { type Page } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
import {
dumpRtcDiagnostics,
getAudioStatsDelta,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
/**
* The signal server pings every 30s and gives up on a socket 45s after the last pong,
* so it needs up to 75s to declare a client dead and broadcast `user_left`.
*/
const DEAD_SOCKET_HOLD_MS = 95_000;
/**
* Outgoing voice used to be 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 sleeping laptop, a flaky wifi hop, or a dropped
* socket wiped that copy - and the observer cut its microphone to a peer that never
* left the channel.
*/
test.describe('Losing a peer from the roster must not silence the call', () => {
// The roster wipe is injected directly, because reproducing it through a real outage
// depends on whether the observer notices the dead transport before `user_left`
// arrives - the reducer keeps the voice state while a live peer transport exists.
test('keeps sending to a peer the roster forgot', async ({ createClient }) => {
test.setTimeout(300_000);
const clients = await createVoicePairInNewServer(
createClient,
`Roster Wipe Voice ${Date.now()}`,
{ namePrefix: 'Roster Wipe' }
);
const [peer, observer] = clients;
for (const client of clients) {
await assertTwoWayAudio(client, 'before the roster wipe');
}
await test.step('The observer is told the peer left the server', async () => {
const wipedUserId = await wipeRemoteVoiceMembersFromRoster(observer.page);
test.info().annotations.push({ type: 'wiped user', description: wipedUserId });
await waitForNoRemoteVoiceMembersInRoster(observer.page, 15_000);
});
// Nothing about the media plane changed, so the peer must not lose a single second of
// audio. Checking only the end state would hide the cut: the peer keeps sending voice
// heartbeats, so the roster heals itself moments later.
await test.step('The peer never stops receiving the observer microphone', async () => {
await assertUninterruptedInboundAudio(peer, 10);
});
});
/**
* The sleep/wake shape without a suspend: one client loses its signal socket long
* enough for the server to declare it dead, and its peer connections die with it. When
* everything returns the peer re-identifies with no voice state attached, so asking the
* peer over the rebuilt data channel is the only thing that can confirm it is still in
* our channel.
*
* `recovery-preserves-media.spec.ts` cannot reach this: killing the server leaves
* nobody to broadcast `user_left`.
*/
test('restores two-way voice after the server declares one client dead', async ({ createClient }) => {
test.setTimeout(600_000);
const clients = await createVoicePairInNewServer(
createClient,
`Roster Loss Voice ${Date.now()}`,
{ namePrefix: 'Roster Loss' }
);
const [droppedClient, observer] = clients;
for (const client of clients) {
await assertTwoWayAudio(client, 'before the outage');
}
await test.step('One client loses its signal socket and its peer connections', async () => {
await droppedClient.context.setOffline(true);
await closeTrackedPeerConnections(droppedClient.page);
await observer.page.waitForTimeout(DEAD_SOCKET_HOLD_MS);
});
await test.step('Both clients are two-way again once the socket returns', async () => {
await droppedClient.context.setOffline(false);
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 180_000);
await waitForOpenDataChannelCount(client.page, 1, 180_000);
}
for (const client of clients) {
await assertTwoWayAudio(client, 'after the socket returned', 90_000);
}
});
});
});
/** Fail unless the client both sends and receives voice packets within the timeout. */
async function assertTwoWayAudio(
client: VoicePairClient,
label: string,
timeoutMs = 60_000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let outboundPacketsDelta = 0;
let inboundPacketsDelta = 0;
while (Date.now() < deadline) {
({ outboundPacketsDelta, inboundPacketsDelta } = await getAudioStatsDelta(client.page, 3_000));
if (outboundPacketsDelta > 0 && inboundPacketsDelta > 0) {
return;
}
}
throw new Error(
`${client.displayName} is not two-way ${label}: sent ${outboundPacketsDelta}, `
+ `received ${inboundPacketsDelta} packets in the last sample.\n`
+ await dumpRtcDiagnostics(client.page)
);
}
/**
* Fail if the client goes even one second without receiving voice packets. Peers gossip
* their voice state every 5s, so a torn-down microphone comes back on its own - only a
* continuous sample can tell that the audio never stopped.
*/
async function assertUninterruptedInboundAudio(
client: VoicePairClient,
seconds: number
): Promise<void> {
for (let sample = 1; sample <= seconds; sample++) {
const { inboundPacketsDelta } = await getAudioStatsDelta(client.page, 1_000);
if (inboundPacketsDelta === 0) {
throw new Error(
`${client.displayName} stopped receiving voice ${sample}s after the roster wipe.\n`
+ await dumpRtcDiagnostics(client.page)
);
}
}
}
/** Kill the media plane the way a suspend does, leaving the peer to notice on its own. */
async function closeTrackedPeerConnections(page: Page): Promise<void> {
await page.evaluate(() => {
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
for (const connection of connections) {
connection.close();
}
});
}
/**
* Replay what the signal server does when it declares a socket dead: tell this client the
* remote user left the server, with no live transport recorded. Returns the wiped user id.
*/
async function wipeRemoteVoiceMembersFromRoster(page: Page): Promise<string> {
return page.evaluate(() => {
interface RosterUser {
id?: string;
oderId?: string;
peerId?: string;
voiceState?: { isConnected?: boolean };
}
interface StoreLike {
dispatch: (action: { type: string } & Record<string, unknown>) => void;
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API is unavailable, cannot reach the store');
}
const component = debugApi.getComponent(host);
const store = component['store'] as StoreLike | undefined;
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
const currentRoom = (component['currentRoom'] as (() => { id?: string } | null) | undefined)?.() ?? null;
const remoteVoiceUser = users.find((user) =>
user.voiceState?.isConnected === true
&& user.id !== currentUser?.id
&& user.oderId !== currentUser?.oderId);
if (!store || !remoteVoiceUser?.id || !currentRoom?.id) {
throw new Error('No remote voice member to wipe from the roster');
}
store.dispatch({
type: '[Users] User Left',
userId: remoteVoiceUser.id,
serverId: currentRoom.id,
connectedPeerIds: []
});
return remoteVoiceUser.id;
});
}
/** Wait until no remote user in the client's roster claims to be in voice. */
async function waitForNoRemoteVoiceMembersInRoster(page: Page, timeout: number): Promise<void> {
await page.waitForFunction(
() => {
interface RosterUser {
id?: string;
oderId?: string;
peerId?: string;
voiceState?: { isConnected?: boolean };
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
const selfIds = new Set([
currentUser?.id,
currentUser?.oderId,
currentUser?.peerId
].filter(Boolean));
return users
.filter((user) => ![
user.id,
user.oderId,
user.peerId
].some((id) => !!id && selfIds.has(id)))
.every((user) => user.voiceState?.isConnected !== true);
},
undefined,
{ timeout }
);
}
@@ -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;
}
+2 -1
View File
@@ -181,7 +181,8 @@
"microphone": "Microphone", "microphone": "Microphone",
"speaker": "Speaker", "speaker": "Speaker",
"microphoneFallback": "Microphone {{index}}", "microphoneFallback": "Microphone {{index}}",
"speakerFallback": "Speaker {{index}}" "speakerFallback": "Speaker {{index}}",
"systemDefault": "System default"
}, },
"volume": { "volume": {
"title": "Volume", "title": "Volume",
+4
View File
@@ -7,6 +7,10 @@
"retry": "Retry", "retry": "Retry",
"failedConnect": "Failed to connect voice session." "failedConnect": "Failed to connect voice session."
}, },
"devices": {
"inputFellBack": "Your microphone was disconnected. Switched to the system default.",
"outputFellBack": "Your speaker was disconnected. Switched to the system default."
},
"floating": { "floating": {
"backToServer": "Back to {{server}}", "backToServer": "Back to {{server}}",
"voiceFallback": "Voice", "voiceFallback": "Voice",
@@ -81,6 +81,10 @@ export class VoiceConnectionFacade {
await this.realtime.setLocalStream(stream); await this.realtime.setLocalStream(stream);
} }
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
return await this.realtime.switchInputDevice(deviceId);
}
toggleMute(muted?: boolean): void { toggleMute(muted?: boolean): void {
this.realtime.toggleMute(muted); this.realtime.toggleMute(muted);
} }
@@ -120,4 +124,8 @@ export class VoiceConnectionFacade {
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void { syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
this.realtime.syncOutgoingVoiceRouting(allowedPeerIds); this.realtime.syncOutgoingVoiceRouting(allowedPeerIds);
} }
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
return this.realtime.mayHearPeerVoice(peerId, hasEstablishedPlayback);
}
} }
@@ -27,7 +27,7 @@ import {
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { DebuggingService } from '../../../../core/services/debugging.service'; import { DebuggingService } from '../../../../core/services/debugging.service';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ /* eslint-disable @typescript-eslint/prefer-for-of, max-statements-per-line */
const SPEAKING_THRESHOLD = 0.015; const SPEAKING_THRESHOLD = 0.015;
const SILENT_FRAME_GRACE = 8; const SILENT_FRAME_GRACE = 8;
@@ -50,6 +50,8 @@ export class VoiceActivityService implements OnDestroy {
private readonly debugging = inject(DebuggingService); private readonly debugging = inject(DebuggingService);
private readonly tracked = new Map<string, TrackedStream>(); private readonly tracked = new Map<string, TrackedStream>();
/** The id the local microphone is tracked under, so it can follow a device switch. */
private localMicUserId: string | null = null;
private animFrameId: number | null = null; private animFrameId: number | null = null;
private readonly subs: Subscription[] = []; private readonly subs: Subscription[] = [];
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map()); private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
@@ -84,13 +86,30 @@ export class VoiceActivityService implements OnDestroy {
} }
trackLocalMic(userId: string, stream: MediaStream): void { trackLocalMic(userId: string, stream: MediaStream): void {
this.localMicUserId = userId;
this.trackStream(userId, stream); this.trackStream(userId, stream);
} }
untrackLocalMic(userId: string): void { untrackLocalMic(userId: string): void {
if (this.localMicUserId === userId) {
this.localMicUserId = null;
}
this.untrackStream(userId); this.untrackStream(userId);
} }
/**
* Point the local speaking indicator at a replacement microphone stream,
* so switching devices mid-call does not leave it watching a dead track.
*/
refreshLocalMicStream(stream: MediaStream): void {
if (!this.localMicUserId) {
return;
}
this.trackStream(this.localMicUserId, stream);
}
isSpeaking(userId: string): Signal<boolean> { isSpeaking(userId: string): Signal<boolean> {
const entry = this.tracked.get(userId); const entry = this.tracked.get(userId);
@@ -13,10 +13,12 @@ import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { VoicePlaybackService } from './voice-playback.service'; import { VoicePlaybackService } from './voice-playback.service';
let audioContextCount = 0; let audioContextCount = 0;
let createdGainNodes: { gain: { value: number } }[] = [];
describe('VoicePlaybackService', () => { describe('VoicePlaybackService', () => {
beforeEach(() => { beforeEach(() => {
audioContextCount = 0; audioContextCount = 0;
createdGainNodes = [];
installAudioDomMocks(); installAudioDomMocks();
installLocalStorageMock(); installLocalStorageMock();
}); });
@@ -94,6 +96,27 @@ describe('VoicePlaybackService', () => {
expect(audioContextCount).toBe(1); expect(audioContextCount).toBe(1);
expect(context.service.getUserVolume('peer-1')).toBe(100); expect(context.service.getUserVolume('peer-1')).toBe(100);
}); });
// The roster is gossip. A peer we already receive audio from stays audible unless the
// voice session has positive evidence it left, otherwise a dropped socket silences a
// member who never went anywhere.
it('keeps a peer audible while the voice session holds the path open', () => {
const context = createServiceContext({ isVoiceConnected: true });
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
expect(context.voiceConnection.mayHearPeerVoice).toHaveBeenCalledWith('peer-1', true);
expect(createdGainNodes.at(-1)?.gain.value).toBe(1);
});
it('mutes a peer the voice session reports as gone from our channel', () => {
const context = createServiceContext({ isVoiceConnected: true });
context.voiceConnection.mayHearPeerVoice.mockReturnValue(false);
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-a']), connectedOptions());
expect(createdGainNodes.at(-1)?.gain.value).toBe(0);
});
}); });
interface ServiceContext { interface ServiceContext {
@@ -106,6 +129,7 @@ interface ServiceContext {
getRemoteVoiceStream: ReturnType<typeof vi.fn>; getRemoteVoiceStream: ReturnType<typeof vi.fn>;
getConnectedPeers: ReturnType<typeof vi.fn>; getConnectedPeers: ReturnType<typeof vi.fn>;
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>; syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
mayHearPeerVoice: ReturnType<typeof vi.fn>;
}; };
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>; remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
} }
@@ -119,7 +143,8 @@ function createServiceContext(options: { isVoiceConnected?: boolean } = {}): Ser
onPeerDisconnected: new Subject<string>(), onPeerDisconnected: new Subject<string>(),
getRemoteVoiceStream: vi.fn(() => null), getRemoteVoiceStream: vi.fn(() => null),
getConnectedPeers: vi.fn(() => []), getConnectedPeers: vi.fn(() => []),
syncOutgoingVoiceRouting: vi.fn() syncOutgoingVoiceRouting: vi.fn(),
mayHearPeerVoice: vi.fn(() => true)
}; };
const screenShare = { const screenShare = {
isScreenShareRemotePlaybackSuppressed: signal(false), isScreenShareRemotePlaybackSuppressed: signal(false),
@@ -237,10 +262,13 @@ function installAudioDomMocks(): void {
} }
createGain() { createGain() {
return { const gainNode = {
gain: { value: 0 }, gain: { value: 0 },
connect: vi.fn() connect: vi.fn()
}; };
createdGainNodes.push(gainNode);
return gainNode;
} }
createMediaStreamSource() { createMediaStreamSource() {
@@ -7,7 +7,6 @@ import { Store } from '@ngrx/store';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service'; import { jsonStorage } from '../../../../infrastructure/persistence/json-storage.service';
import { ScreenShareFacade } from '../../../../domains/screen-share'; import { ScreenShareFacade } from '../../../../domains/screen-share';
import { User } from '../../../../shared-kernel';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
@@ -331,7 +330,7 @@ export class VoicePlaybackService {
if (!pipeline) if (!pipeline)
return; return;
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.isPeerInCurrentVoiceRoom(peerId)) { if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.mayHearPeer(peerId)) {
pipeline.gainNode.gain.value = 0; pipeline.gainNode.gain.value = 0;
return; return;
} }
@@ -346,22 +345,17 @@ export class VoicePlaybackService {
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId)); this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
} }
private isPeerInCurrentVoiceRoom(peerId: string): boolean { /**
const localVoiceState = this.currentUser()?.voiceState; * Whether this peer's audio may be audible.
*
if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) { * The roster is gossip: the signal server broadcasts `user_left` for any socket it
return false; * declares dead, which used to mute a peer still sitting in our channel. Playback
} * therefore asks the voice session, which weighs the roster against what the peer
* itself reported over its data channel, and holds a stream we already receive when
const remoteVoiceState = this.findUserForPeer(peerId)?.voiceState; * neither confirms nor denies.
*/
return !!remoteVoiceState?.isConnected private mayHearPeer(peerId: string): boolean {
&& remoteVoiceState.roomId === localVoiceState.roomId return this.voiceConnection.mayHearPeerVoice(peerId, this.rawRemoteStreams.has(peerId));
&& remoteVoiceState.serverId === localVoiceState.serverId;
}
private findUserForPeer(peerId: string): User | undefined {
return this.allUsers().find((user) => user.id === peerId || user.oderId === peerId || user.peerId === peerId);
} }
private syncOutgoingVoiceRouting(): void { private syncOutgoingVoiceRouting(): void {
@@ -12,10 +12,14 @@ voice-session/
│ ├── facades/ │ ├── facades/
│ │ └── voice-session.facade.ts Tracks active voice session, drives floating controls │ │ └── voice-session.facade.ts Tracks active voice session, drives floating controls
│ └── services/ │ └── services/
│ ├── voice-audio-device.service.ts Microphone/speaker selection, live apply, devicechange fallback
│ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position │ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position
├── domain/ ├── domain/
│ ├── logic/ │ ├── logic/
│ │ ├── audio-device-selection.rules.ts Device fallback decisions + getUserMedia constraints
│ │ ├── stream-indicator.rules.ts Whether a user's LIVE indicator shows
│ │ ├── voice-path-routing.rules.ts Whether a voice path with a peer may carry audio
│ │ └── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom │ │ └── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom
│ └── models/ │ └── models/
│ └── voice-session.model.ts VoiceSessionInfo interface │ └── voice-session.model.ts VoiceSessionInfo interface
@@ -92,6 +96,12 @@ Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceSt
Rules live in `domain/logic/client-voice-session.rules.ts`. Rules live in `domain/logic/client-voice-session.rules.ts`.
Every media path to a peer — the microphone we send, the camera we send, and the audio we play back — is decided per peer by `decideVoicePathRouting` (`domain/logic/voice-path-routing.rules.ts`). `MediaManager.syncVoiceRouting()` and `syncCameraRouting()` call it on every routing pass, `mayHearPeerVoice()` reuses it for playback gain, and `mayOpenVoicePathToPeer()` answers the same question for a peer connection being built, so `createPeerConnection` cannot put the microphone in a first offer that routing would refuse. A peer therefore cannot be audible while muted-by-routing, or keep our camera after losing our microphone, or start receiving either one because a connection happened to be created while we were in voice.
Presence in the roster or the peer's own `voice-state` message opens a path; only positive evidence closes one — we left voice, the peer said it is not in our channel, or the peer connection is closed. Everything else is `hold`: an already-negotiated path stays up. A peer missing from the roster is not evidence it left voice, because the signal server broadcasts `user_left` for any socket it declares dead; treating that as a departure used to detach the microphone from a peer still sitting in the channel, leaving one side of the call permanently silent. A path that was never negotiated still stays closed until something confirms the peer, so a guess can never start sending the microphone. When the peer and the roster disagree, the peer's own report wins unless it is older than the roster claim.
Regression cover: `voice-path-routing.rules.spec.ts`, the routing, first-offer, and camera cases in `media.manager.spec.ts`, the gate case in `create-peer-connection.spec.ts`, and `e2e/tests/voice/roster-loss-preserves-voice.spec.ts`.
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session. Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move. Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
@@ -112,6 +122,8 @@ stateDiagram-v2
Minimized --> Hidden: voice session ends Minimized --> Hidden: voice session ends
``` ```
A user's LIVE indicator is decided by `shouldShowStreamIndicator` (`domain/logic/stream-indicator.rules.ts`) from the observed user's state alone — whether they are in a voice channel, what they announced over the peer plane, and any live track. The observer's own voice session is deliberately not part of that decision, so someone sharing alone is visible to everyone in the server; clicking the badge from outside the channel joins that channel first and then focuses the stream.
The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which live stream gets the widescreen treatment in expanded mode, using feature-level stream IDs such as `screen:<peerKey>` or `camera:<peerKey>`. The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which live stream gets the widescreen treatment in expanded mode, using feature-level stream IDs such as `screen:<peerKey>` or `camera:<peerKey>`.
## Voice settings ## Voice settings
@@ -132,3 +144,12 @@ Settings are stored in localStorage under a single JSON key. All values are vali
| includeSystemAudio | `false` | boolean | | includeSystemAudio | `false` | boolean |
`loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields. `loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields.
## Audio devices: one owner, applied live
`VoiceAudioDeviceService` owns `inputDevice` and `outputDevice`. Every surface that offers a picker — the settings modal today — calls it instead of writing storage itself, so a picker cannot be live in one place and inert in another.
- **Changing the microphone never leaves voice.** The service calls `VoiceConnectionFacade.switchInputDevice()`, which re-captures the mic and swaps the track into the existing peer senders via `replaceTrack`. There is no disconnect, no rejoin broadcast, and no SDP renegotiation, so remote peers keep the same audio track. Proven by `e2e/tests/voice/live-input-device-change.spec.ts`.
- **Changing the speaker** re-applies the sink to every live playback pipeline (`VoicePlaybackService.applyOutputDevice`).
- **A device disappearing** falls back to the system default and sets `deviceNotice` (a translation key) for the UI. An empty device list is treated as missing evidence, never as an unplugged device: browsers report no devices before the microphone permission is granted, and Firefox never enumerates audio outputs.
- **Mute and deafen are not owned here.** `MediaManager` holds them and the state reaches the UI through `VoiceConnectionFacade.isMuted` / `isDeafened`. Components must read those signals rather than keeping a local copy, otherwise two control surfaces disagree after a swap or a rejoin.
@@ -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;
}
};
}
@@ -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 }));
}
@@ -0,0 +1,109 @@
import {
describe,
expect,
it
} from 'vitest';
import {
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
buildMicrophoneConstraints,
isDeviceUnavailableError,
resolveAudioDeviceSelection
} from './audio-device-selection.rules';
describe('resolveAudioDeviceSelection', () => {
it('keeps a selection that is still present in the device list', () => {
const outcome = resolveAudioDeviceSelection({
selectedDeviceId: 'headset',
availableDeviceIds: [
'default',
'headset',
'webcam-mic'
]
});
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
});
it('reports the system default when nothing is selected', () => {
const outcome = resolveAudioDeviceSelection({
selectedDeviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
availableDeviceIds: ['default', 'headset']
});
expect(outcome).toEqual({ deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false });
});
it('falls back to the system default when the selected device disappeared', () => {
const outcome = resolveAudioDeviceSelection({
selectedDeviceId: 'headset',
availableDeviceIds: ['default', 'webcam-mic']
});
expect(outcome).toEqual({ deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true });
});
it('keeps the selection when the device list is empty, because an empty list is no evidence', () => {
const outcome = resolveAudioDeviceSelection({
selectedDeviceId: 'headset',
availableDeviceIds: []
});
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
});
it('keeps the selection when the list only holds unlabelled placeholder ids', () => {
const outcome = resolveAudioDeviceSelection({
selectedDeviceId: 'headset',
availableDeviceIds: ['', '']
});
expect(outcome).toEqual({ deviceId: 'headset', didFallBack: false });
});
});
describe('buildMicrophoneConstraints', () => {
// A bare id is only a preference: the browser may return the current default
// instead, which makes the device picker look broken on real hardware.
it('requires the chosen device exactly', () => {
const constraints = buildMicrophoneConstraints({
deviceId: 'headset',
browserNoiseSuppression: false
});
expect(constraints.audio).toMatchObject({ deviceId: { exact: 'headset' } });
expect(constraints.video).toBe(false);
});
it('omits deviceId entirely when following the system default', () => {
const constraints = buildMicrophoneConstraints({
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
browserNoiseSuppression: true
});
expect(constraints.audio).not.toHaveProperty('deviceId');
});
it('passes the browser noise-suppression choice through', () => {
expect(
buildMicrophoneConstraints({ deviceId: '', browserNoiseSuppression: true }).audio
).toMatchObject({ noiseSuppression: true, echoCancellation: true, autoGainControl: true });
expect(
buildMicrophoneConstraints({ deviceId: '', browserNoiseSuppression: false }).audio
).toMatchObject({ noiseSuppression: false });
});
});
describe('isDeviceUnavailableError', () => {
it('recognises the errors that mean the exact device cannot be used', () => {
expect(isDeviceUnavailableError({ name: 'OverconstrainedError' })).toBe(true);
expect(isDeviceUnavailableError({ name: 'NotFoundError' })).toBe(true);
});
// A denied permission must keep failing instead of silently opening another mic.
it('does not treat a denied permission or an unknown failure as a missing device', () => {
expect(isDeviceUnavailableError({ name: 'NotAllowedError' })).toBe(false);
expect(isDeviceUnavailableError(new Error('boom'))).toBe(false);
expect(isDeviceUnavailableError(null)).toBe(false);
});
});
@@ -0,0 +1,91 @@
/**
* Pure decisions about which audio device to capture from or play to.
*
* The browser device list is unreliable evidence: it is empty before the
* microphone permission is granted and can hold unlabelled placeholder
* entries, so "my device is not in the list" only means the device is gone
* when the list itself is trustworthy.
*/
/** The stored value that means "follow whatever the operating system picked". */
export const SYSTEM_DEFAULT_AUDIO_DEVICE_ID = '';
export interface AudioDeviceSelectionInput {
/** The user's saved choice, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}. */
readonly selectedDeviceId: string;
/** Device ids the browser currently reports for this device kind. */
readonly availableDeviceIds: readonly string[];
}
export interface AudioDeviceSelectionOutcome {
/** The device id to use now. */
readonly deviceId: string;
/** True only when a real device list proved the saved choice is gone. */
readonly didFallBack: boolean;
}
export interface MicrophoneConstraintsInput {
readonly deviceId: string;
/** Whether the browser's own noise suppression should run (off when RNNoise handles it). */
readonly browserNoiseSuppression: boolean;
}
/**
* Decide which device to use, falling back to the system default when the
* saved choice is provably gone.
*/
export function resolveAudioDeviceSelection(
input: AudioDeviceSelectionInput
): AudioDeviceSelectionOutcome {
const { selectedDeviceId } = input;
if (!selectedDeviceId) {
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: false };
}
const knownDeviceIds = input.availableDeviceIds.filter((deviceId) => !!deviceId);
if (knownDeviceIds.length === 0) {
return { deviceId: selectedDeviceId, didFallBack: false };
}
if (knownDeviceIds.includes(selectedDeviceId)) {
return { deviceId: selectedDeviceId, didFallBack: false };
}
return { deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID, didFallBack: true };
}
/**
* Build the `getUserMedia` constraints for the microphone.
*
* `deviceId` is `exact`, because a bare id is only a preference the browser may
* ignore - against real hardware it can hand back the current default, so the
* picker appears to do nothing. Callers must handle `OverconstrainedError` by
* retrying with the system default (see `isDeviceUnavailableError`).
*/
export function buildMicrophoneConstraints(
input: MicrophoneConstraintsInput
): MediaStreamConstraints {
const audio: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: input.browserNoiseSuppression,
autoGainControl: true
};
if (input.deviceId) {
audio.deviceId = { exact: input.deviceId };
}
return { audio, video: false };
}
/**
* Whether a `getUserMedia` rejection means "that exact device is not usable",
* as opposed to a denied permission or a missing API, which must keep failing.
*/
export function isDeviceUnavailableError(error: unknown): boolean {
const name = (error as { name?: string } | null)?.name;
return name === 'OverconstrainedError' || name === 'NotFoundError';
}
@@ -0,0 +1,160 @@
import {
describe,
expect,
it
} from 'vitest';
import {
decideVoicePathRouting,
isSameVoiceChannel,
type VoicePathRoutingInput
} from './voice-path-routing.rules';
const OUR_CHANNEL = { roomId: 'voice-1', serverId: 'server-1' };
function input(overrides: Partial<VoicePathRoutingInput> = {}): VoicePathRoutingInput {
return {
hasLocalVoice: true,
localVoiceChannel: OUR_CHANNEL,
rosterPresenceAt: 1_000,
peerReport: null,
hasEstablishedPath: true,
isPeerConnectionClosed: false,
...overrides
};
}
describe('decideVoicePathRouting', () => {
it('opens the path to a peer the roster lists in our channel', () => {
expect(decideVoicePathRouting(input())).toBe('open');
});
it('opens the path to a peer that reports our channel itself', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: null,
peerReport: {
at: 2_000,
isConnected: true,
...OUR_CHANNEL
}
}));
expect(decision).toBe('open');
});
// The bug this file exists for: a dead socket makes the server broadcast
// `user_left`, which wipes the roster copy of a peer that never left voice.
it('holds an established path when the roster no longer knows the peer', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: null,
peerReport: null,
hasEstablishedPath: true
}));
expect(decision).toBe('hold');
});
it('never opens a new path for a peer nothing confirms', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: null,
peerReport: null,
hasEstablishedPath: false
}));
expect(decision).toBe('close');
});
it('closes the path when the peer answers that it is not in voice', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: null,
peerReport: {
at: 2_000,
isConnected: false
}
}));
expect(decision).toBe('close');
});
it('closes the path when the peer reports another voice channel', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: null,
peerReport: {
at: 2_000,
isConnected: true,
roomId: 'voice-2',
serverId: 'server-1'
}
}));
expect(decision).toBe('close');
});
it('lets the peer own word overrule a roster that still lists it', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: 1_000,
peerReport: {
at: 1_500,
isConnected: false
}
}));
expect(decision).toBe('close');
});
it('ignores a departure report the roster already superseded', () => {
const decision = decideVoicePathRouting(input({
rosterPresenceAt: 2_000,
peerReport: {
at: 1_500,
isConnected: false
}
}));
expect(decision).toBe('open');
});
it('closes every path once we leave voice ourselves', () => {
expect(decideVoicePathRouting(input({ hasLocalVoice: false }))).toBe('close');
});
// Voice starts before the channel is recorded, so an unknown local channel is not
// a reason to cut anything - it just cannot contradict the peer.
it('falls back to the roster while our own channel is still unknown', () => {
expect(decideVoicePathRouting(input({ localVoiceChannel: null }))).toBe('open');
const unverifiableReport = decideVoicePathRouting(input({
localVoiceChannel: null,
rosterPresenceAt: null,
peerReport: {
at: 2_000,
isConnected: true,
roomId: 'voice-9',
serverId: 'server-9'
}
}));
expect(unverifiableReport).toBe('hold');
});
it('closes the path when the peer connection can no longer carry media', () => {
const decision = decideVoicePathRouting(input({
isPeerConnectionClosed: true,
peerReport: {
at: 2_000,
isConnected: true,
...OUR_CHANNEL
}
}));
expect(decision).toBe('close');
});
});
describe('isSameVoiceChannel', () => {
it('matches only when both room and server are known and equal', () => {
expect(isSameVoiceChannel(OUR_CHANNEL, { ...OUR_CHANNEL })).toBe(true);
expect(isSameVoiceChannel(OUR_CHANNEL, { roomId: 'voice-2', serverId: 'server-1' })).toBe(false);
expect(isSameVoiceChannel(OUR_CHANNEL, { roomId: 'voice-1' })).toBe(false);
expect(isSameVoiceChannel(null, OUR_CHANNEL)).toBe(false);
});
});
@@ -0,0 +1,112 @@
/**
* Pure decisions about whether a voice path with one peer may stay open - both the
* microphone we send and the audio we play back.
*
* Voice used to be gated purely on the observer's roster copy of the remote user's
* voice state, which is signaling gossip. When a socket dies the signal server
* broadcasts `user_left`, the roster copy is wiped, and the observer then cut the
* media path to a peer that never left the channel - a silent member with no way
* back through the UI.
*
* Missing gossip about a peer is not evidence the peer left voice, so it must never
* close a negotiated path. The reverse is not symmetric: a path we never opened stays
* closed until something positively confirms the peer is in our channel, so a guess
* can never start sending our microphone to anyone.
*
* The peer's own voice report (data channel `voice-state`) mirrors its local voice
* truth, so it outranks the roster when the two disagree and the report is not older
* than the roster claim.
*/
/** The voice channel a client is in: a channel of a server, or a direct call. */
export interface VoiceChannelRef {
readonly roomId?: string;
readonly serverId?: string;
}
/** What a peer last told us about its own voice membership. */
export interface PeerVoiceReport extends VoiceChannelRef {
/** When the peer told us, in `Date.now()` milliseconds. */
readonly at: number;
readonly isConnected: boolean;
}
export interface VoicePathRoutingInput {
/** We are in voice ourselves. */
readonly hasLocalVoice: boolean;
/** The voice channel we are in, from our own state - never from gossip. */
readonly localVoiceChannel: VoiceChannelRef | null;
/** When the roster last started listing this peer in our channel, or `null` when it does not. */
readonly rosterPresenceAt: number | null;
/** The peer's own last voice report, or `null` when it never sent one. */
readonly peerReport: PeerVoiceReport | null;
/** A negotiated media path with this peer already exists. */
readonly hasEstablishedPath: boolean;
/** The peer connection can no longer carry media at all. */
readonly isPeerConnectionClosed: boolean;
}
/**
* - `open`: the peer is confirmed in our channel; carry voice.
* - `hold`: nothing confirms or denies, so an established path stays up.
* - `close`: positive evidence that this path must not carry voice.
*/
export type VoicePathRouting = 'open' | 'hold' | 'close';
/** Whether two channel references point at the same voice channel. */
export function isSameVoiceChannel(left: VoiceChannelRef | null, right: VoiceChannelRef | null): boolean {
if (!left?.roomId || !left.serverId || !right?.roomId || !right.serverId) {
return false;
}
return left.roomId === right.roomId && left.serverId === right.serverId;
}
/** Decide whether a voice path with this peer may carry audio right now. */
export function decideVoicePathRouting(input: VoicePathRoutingInput): VoicePathRouting {
const {
hasLocalVoice,
localVoiceChannel,
rosterPresenceAt,
peerReport,
hasEstablishedPath,
isPeerConnectionClosed
} = input;
// We left voice, or the transport is gone: both are positive evidence.
if (!hasLocalVoice) {
return 'close';
}
if (isPeerConnectionClosed) {
return 'close';
}
// Our own channel can still be unset while voice starts, and an unknown channel
// cannot contradict anything the peer says about the channel it is in.
const knowsLocalChannel = !!localVoiceChannel?.roomId && !!localVoiceChannel.serverId;
const reportsOurChannel = !!peerReport
&& peerReport.isConnected
&& knowsLocalChannel
&& isSameVoiceChannel(peerReport, localVoiceChannel);
if (reportsOurChannel) {
return 'open';
}
const reportsAway = !!peerReport
&& (!peerReport.isConnected || (knowsLocalChannel && !isSameVoiceChannel(peerReport, localVoiceChannel)));
const rosterOutranksReport = rosterPresenceAt !== null
&& (!reportsAway || peerReport.at < rosterPresenceAt);
if (rosterOutranksReport) {
return 'open';
}
if (reportsAway) {
return 'close';
}
// Nothing confirms and nothing denies. Only an already-negotiated path survives that.
return hasEstablishedPath ? 'hold' : 'close';
}
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { import {
Component, Component,
inject, inject,
@@ -20,6 +20,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { VoiceSessionFacade } from '../../application/facades/voice-session.facade'; import { VoiceSessionFacade } from '../../application/facades/voice-session.facade';
import { VoiceAudioDeviceService } from '../../application/services/voice-audio-device.service';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection'; import { VoicePlaybackService } from '../../../../domains/voice-connection';
@@ -70,6 +71,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile()); readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
private readonly voiceSessionService = inject(VoiceSessionFacade); private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly audioDevices = inject(VoiceAudioDeviceService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
@@ -80,29 +82,23 @@ export class FloatingVoiceControlsComponent implements OnInit {
voiceSession = this.voiceSessionService.voiceSession; voiceSession = this.voiceSessionService.voiceSession;
isConnected = computed(() => this.webrtcService.isVoiceConnected()); isConnected = computed(() => this.webrtcService.isVoiceConnected());
isMuted = signal(false); /** Same media-layer truth the in-channel controls read, so the two never disagree. */
isDeafened = signal(false); isMuted = computed(() => this.webrtcService.isMuted());
isDeafened = computed(() => this.webrtcService.isDeafened());
isScreenSharing = this.screenShareService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
screenShareQuality = signal<ScreenShareQuality>('balanced'); screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true); askScreenShareQuality = signal(true);
showScreenShareQualityDialog = signal(false); showScreenShareQualityDialog = signal(false);
/** Sync local mute/deafen state from the WebRTC service on init. */
ngOnInit(): void { ngOnInit(): void {
// Sync mute/deafen state from webrtc service
this.isMuted.set(this.webrtcService.isMuted());
this.isDeafened.set(this.webrtcService.isDeafened());
this.syncScreenShareSettings(); this.syncScreenShareSettings();
const settings = loadVoiceSettingsFromStorage(); const settings = loadVoiceSettingsFromStorage();
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100); this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
this.voicePlayback.updateDeafened(this.isDeafened()); this.voicePlayback.updateDeafened(this.isDeafened());
this.audioDevices.applySelectedOutputDevice();
if (settings.outputDevice) {
this.voicePlayback.applyOutputDevice(settings.outputDevice);
}
} }
backToServerTitle(): string { backToServerTitle(): string {
@@ -118,8 +114,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
/** Toggle microphone mute and broadcast the updated voice state. */ /** Toggle microphone mute and broadcast the updated voice state. */
toggleMute(): void { toggleMute(): void {
this.isMuted.update((current) => !current); this.webrtcService.toggleMute(!this.isMuted());
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change // Broadcast mute state change
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
@@ -136,13 +131,11 @@ export class FloatingVoiceControlsComponent implements OnInit {
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */ /** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
toggleDeafen(): void { toggleDeafen(): void {
this.isDeafened.update((current) => !current); this.webrtcService.toggleDeafen(!this.isDeafened());
this.webrtcService.toggleDeafen(this.isDeafened());
this.voicePlayback.updateDeafened(this.isDeafened()); this.voicePlayback.updateDeafened(this.isDeafened());
// When deafening, also mute // When deafening, also mute
if (this.isDeafened() && !this.isMuted()) { if (this.isDeafened() && !this.isMuted()) {
this.isMuted.set(true);
this.webrtcService.toggleMute(true); this.webrtcService.toggleMute(true);
} }
@@ -211,7 +204,7 @@ export class FloatingVoiceControlsComponent implements OnInit {
// Disable voice // Disable voice
this.webrtcService.disableVoice(); this.webrtcService.disableVoice();
this.voicePlayback.teardownAll(); this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false); this.voicePlayback.updateDeafened(this.isDeafened());
// Update user voice state in store // Update user voice state in store
const user = this.currentUser(); const user = this.currentUser();
@@ -229,10 +222,6 @@ export class FloatingVoiceControlsComponent implements OnInit {
// End voice session // End voice session
this.voiceSessionService.endSession(); this.voiceSessionService.endSession();
// Reset local state
this.isMuted.set(false);
this.isDeafened.set(false);
} }
/** Return the CSS classes for the compact control button based on active state. */ /** Return the CSS classes for the compact control button based on active state. */
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */ /* eslint-disable @typescript-eslint/no-unused-vars, complexity */
import { import {
Component, Component,
ElementRef, ElementRef,
@@ -24,6 +24,8 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { VoiceSessionFacade } from '../../application/facades/voice-session.facade'; import { VoiceSessionFacade } from '../../application/facades/voice-session.facade';
import { VoiceAudioDeviceService } from '../../application/services/voice-audio-device.service';
import { buildMicrophoneConstraints } from '../../domain/logic/audio-device-selection.rules';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/util/voice-settings-storage.util';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection'; import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection';
@@ -43,11 +45,6 @@ import {
} from '../../../../shared'; } from '../../../../shared';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
interface AudioDevice {
deviceId: string;
label: string;
}
@Component({ @Component({
selector: 'app-voice-controls', selector: 'app-voice-controls',
standalone: true, standalone: true,
@@ -79,6 +76,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private readonly webrtcService = inject(VoiceConnectionFacade); private readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade); private readonly screenShareService = inject(ScreenShareFacade);
private readonly voiceSessionService = inject(VoiceSessionFacade); private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly audioDevices = inject(VoiceAudioDeviceService);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store); private readonly store = inject(Store);
@@ -104,8 +102,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return this.appI18n.instant(message); return this.appI18n.instant(message);
}); });
isMuted = signal(false); /** Mute and deafen are owned by the media layer; every surface reads the same truth. */
isDeafened = signal(false); isMuted = computed(() => this.webrtcService.isMuted());
isDeafened = computed(() => this.webrtcService.isDeafened());
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled()); isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
isScreenSharing = this.screenShareService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
showSettings = signal(false); showSettings = signal(false);
@@ -123,10 +122,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true }); this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
} }
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100); inputVolume = signal(100);
outputVolume = signal(100); outputVolume = signal(100);
audioBitrate = signal(96); audioBitrate = signal(96);
@@ -145,7 +140,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}; };
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.loadAudioDevices(); await this.audioDevices.refreshDevices();
// Load persisted voice settings and apply // Load persisted voice settings and apply
this.loadSettings(); this.loadSettings();
@@ -158,24 +153,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
} }
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices.filter((device) => device.kind === 'audioinput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
this.outputDevices.set(
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
} catch (_error) {}
}
async connect(): Promise<void> { async connect(): Promise<void> {
try { try {
// Require signaling connectivity first // Require signaling connectivity first
@@ -195,13 +172,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return; return;
} }
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia(
audio: { buildMicrophoneConstraints({
deviceId: this.selectedInputDevice() || undefined, deviceId: this.audioDevices.selectedInputDeviceId(),
echoCancellation: true, browserNoiseSuppression: !this.noiseReduction()
noiseSuppression: !this.noiseReduction() })
} );
});
await this.webrtcService.setLocalStream(stream); await this.webrtcService.setLocalStream(stream);
@@ -305,7 +281,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Disable voice (stops audio tracks but keeps peer connections open for chat) // Disable voice (stops audio tracks but keeps peer connections open for chat)
this.webrtcService.disableVoice(); this.webrtcService.disableVoice();
this.voicePlayback.teardownAll(); this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false); this.voicePlayback.updateDeafened(this.isDeafened());
const user = this.currentUser(); const user = this.currentUser();
@@ -333,14 +309,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// End voice session for floating controls // End voice session for floating controls
this.voiceSessionService.endSession(); this.voiceSessionService.endSession();
this.isMuted.set(false);
this.isDeafened.set(false);
} }
toggleMute(): void { toggleMute(): void {
this.isMuted.update((current) => !current); this.webrtcService.toggleMute(!this.isMuted());
this.webrtcService.toggleMute(this.isMuted());
// Update local store so the side panel reflects the mute state // Update local store so the side panel reflects the mute state
const user = this.currentUser(); const user = this.currentUser();
@@ -372,14 +344,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
toggleDeafen(): void { toggleDeafen(): void {
this.isDeafened.update((current) => !current); this.webrtcService.toggleDeafen(!this.isDeafened());
this.webrtcService.toggleDeafen(this.isDeafened());
this.voicePlayback.updateDeafened(this.isDeafened()); this.voicePlayback.updateDeafened(this.isDeafened());
// When deafening, also mute // When deafening, also mute
if (this.isDeafened() && !this.isMuted()) { if (this.isDeafened() && !this.isMuted()) {
this.isMuted.set(true);
this.webrtcService.toggleMute(true); this.webrtcService.toggleMute(true);
} }
@@ -492,28 +461,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.showSettings.set(false); this.showSettings.set(false);
} }
onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value);
// Reconnect with new device if connected
if (this.isConnected()) {
this.disconnect();
this.connect();
}
this.saveSettings();
}
onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value);
this.applyOutputDevice();
this.saveSettings();
}
onInputVolumeChange(event: Event): void { onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -567,8 +514,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private loadSettings(): void { private loadSettings(): void {
const settings = loadVoiceSettingsFromStorage(); const settings = loadVoiceSettingsFromStorage();
this.selectedInputDevice.set(settings.inputDevice);
this.selectedOutputDevice.set(settings.outputDevice);
this.inputVolume.set(settings.inputVolume); this.inputVolume.set(settings.inputVolume);
this.outputVolume.set(settings.outputVolume); this.outputVolume.set(settings.outputVolume);
this.audioBitrate.set(settings.audioBitrate); this.audioBitrate.set(settings.audioBitrate);
@@ -581,8 +526,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private saveSettings(): void { private saveSettings(): void {
saveVoiceSettingsToStorage({ saveVoiceSettingsToStorage({
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(), inputVolume: this.inputVolume(),
outputVolume: this.outputVolume(), outputVolume: this.outputVolume(),
audioBitrate: this.audioBitrate(), audioBitrate: this.audioBitrate(),
@@ -601,22 +544,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.setInputVolume(this.inputVolume() / 100); this.webrtcService.setInputVolume(this.inputVolume() / 100);
this.webrtcService.setAudioBitrate(this.audioBitrate()); this.webrtcService.setAudioBitrate(this.audioBitrate());
this.webrtcService.setLatencyProfile(this.latencyProfile()); this.webrtcService.setLatencyProfile(this.latencyProfile());
this.applyOutputDevice(); this.audioDevices.applySelectedOutputDevice();
// Always sync the desired noise-reduction preference (even before // Always sync the desired noise-reduction preference (even before
// a mic stream exists - the flag will be honoured on connect). // a mic stream exists - the flag will be honoured on connect).
this.webrtcService.toggleNoiseReduction(this.noiseReduction()); this.webrtcService.toggleNoiseReduction(this.noiseReduction());
} catch {} } catch {}
} }
private async applyOutputDevice(): Promise<void> {
const deviceId = this.selectedOutputDevice();
if (!deviceId)
return;
this.voicePlayback.applyOutputDevice(deviceId);
}
private syncScreenShareSettings(): void { private syncScreenShareSettings(): void {
const settings = loadVoiceSettingsFromStorage(); const settings = loadVoiceSettingsFromStorage();
@@ -1,7 +1,11 @@
export * from './application/facades/voice-session.facade'; export * from './application/facades/voice-session.facade';
export * from './application/services/voice-audio-device.service';
export * from './application/services/voice-client-takeover.service'; export * from './application/services/voice-client-takeover.service';
export * from './application/services/voice-workspace.service'; export * from './application/services/voice-workspace.service';
export * from './domain/logic/audio-device-selection.rules';
export * from './domain/logic/client-voice-session.rules'; export * from './domain/logic/client-voice-session.rules';
export * from './domain/logic/stream-indicator.rules';
export * from './domain/logic/voice-path-routing.rules';
export * from './domain/models/voice-session.model'; export * from './domain/models/voice-session.model';
export * from './infrastructure/util/voice-settings-storage.util'; export * from './infrastructure/util/voice-settings-storage.util';
@@ -6,8 +6,16 @@
name="lucideMic" name="lucideMic"
class="w-5 h-5 text-muted-foreground" class="w-5 h-5 text-muted-foreground"
/> />
<h4 class="text-sm font-semibold text-foreground">'settings.voice.devices.title' | translate</h4> <h4 class="text-sm font-semibold text-foreground">{{ 'settings.voice.devices.title' | translate }}</h4>
</div> </div>
@if (deviceNotice(); as notice) {
<p
class="mb-3 text-xs text-destructive"
data-testid="voice-settings-device-notice"
>
{{ notice | translate }}
</p>
}
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label <label
@@ -18,8 +26,15 @@
<select <select
(change)="onInputDeviceChange($event)" (change)="onInputDeviceChange($event)"
id="input-device-select" id="input-device-select"
data-testid="voice-settings-input-device"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option
value=""
[selected]="!selectedInputDevice()"
>
{{ 'settings.voice.devices.systemDefault' | translate }}
</option>
@for (device of inputDevices(); track device.deviceId) { @for (device of inputDevices(); track device.deviceId) {
<option <option
[value]="device.deviceId" [value]="device.deviceId"
@@ -39,8 +54,15 @@
<select <select
(change)="onOutputDeviceChange($event)" (change)="onOutputDeviceChange($event)"
id="output-device-select" id="output-device-select"
data-testid="voice-settings-output-device"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option
value=""
[selected]="!selectedOutputDevice()"
>
{{ 'settings.voice.devices.systemDefault' | translate }}
</option>
@for (device of outputDevices(); track device.deviceId) { @for (device of outputDevices(); track device.deviceId) {
<option <option
[value]="device.deviceId" [value]="device.deviceId"
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -21,17 +21,16 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share'; import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share';
import { screenShareQualityI18nKey } from '../../../../shared-kernel'; import { screenShareQualityI18nKey } from '../../../../shared-kernel';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session'; import {
VoiceAudioDeviceService,
loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage
} from '../../../../domains/voice-session';
import { VoicePlaybackService } from '../../../../domains/voice-connection'; import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
interface AudioDevice {
deviceId: string;
label: string;
}
@Component({ @Component({
selector: 'app-voice-settings', selector: 'app-voice-settings',
standalone: true, standalone: true,
@@ -55,6 +54,7 @@ interface AudioDevice {
export class VoiceSettingsComponent { export class VoiceSettingsComponent {
private voiceConnection = inject(VoiceConnectionFacade); private voiceConnection = inject(VoiceConnectionFacade);
private voicePlayback = inject(VoicePlaybackService); private voicePlayback = inject(VoicePlaybackService);
private readonly audioDevices = inject(VoiceAudioDeviceService);
private electronBridge = inject(ElectronBridgeService); private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService); private platform = inject(PlatformService);
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
@@ -68,10 +68,12 @@ export class VoiceSettingsComponent {
})) }))
); );
inputDevices = signal<AudioDevice[]>([]); readonly inputDevices = this.audioDevices.inputDevices;
outputDevices = signal<AudioDevice[]>([]); readonly outputDevices = this.audioDevices.outputDevices;
selectedInputDevice = signal<string>(''); readonly selectedInputDevice = this.audioDevices.selectedInputDeviceId;
selectedOutputDevice = signal<string>(''); readonly selectedOutputDevice = this.audioDevices.selectedOutputDeviceId;
/** Set when a picked device disappeared and the system default took over. */
readonly deviceNotice = this.audioDevices.deviceNotice;
inputVolume = signal(100); inputVolume = signal(100);
outputVolume = signal(100); outputVolume = signal(100);
audioBitrate = signal(96); audioBitrate = signal(96);
@@ -91,41 +93,16 @@ export class VoiceSettingsComponent {
constructor() { constructor() {
this.loadVoiceSettings(); this.loadVoiceSettings();
this.loadAudioDevices(); void this.audioDevices.refreshDevices();
if (this.isElectron) { if (this.isElectron) {
void this.loadDesktopSettings(); void this.loadDesktopSettings();
} }
} }
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices)
return;
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
);
this.outputDevices.set(
devices
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId,
label: device.label }))
);
} catch {}
}
loadVoiceSettings(): void { loadVoiceSettings(): void {
const settings = loadVoiceSettingsFromStorage(); const settings = loadVoiceSettingsFromStorage();
this.selectedInputDevice.set(settings.inputDevice);
this.selectedOutputDevice.set(settings.outputDevice);
this.inputVolume.set(settings.inputVolume); this.inputVolume.set(settings.inputVolume);
this.outputVolume.set(settings.outputVolume); this.outputVolume.set(settings.outputVolume);
this.audioBitrate.set(settings.audioBitrate); this.audioBitrate.set(settings.audioBitrate);
@@ -147,8 +124,6 @@ export class VoiceSettingsComponent {
saveVoiceSettings(): void { saveVoiceSettings(): void {
saveVoiceSettingsToStorage({ saveVoiceSettingsToStorage({
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(), inputVolume: this.inputVolume(),
outputVolume: this.outputVolume(), outputVolume: this.outputVolume(),
audioBitrate: this.audioBitrate(), audioBitrate: this.audioBitrate(),
@@ -160,19 +135,16 @@ export class VoiceSettingsComponent {
}); });
} }
onInputDeviceChange(event: Event): void { async onInputDeviceChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value); await this.audioDevices.selectInputDevice(select.value);
this.saveVoiceSettings();
} }
onOutputDeviceChange(event: Event): void { async onOutputDeviceChange(event: Event): Promise<void> {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value); await this.audioDevices.selectOutputDevice(select.value);
this.voiceConnection.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
} }
onInputVolumeChange(event: Event): void { onInputVolumeChange(event: Event): void {
@@ -0,0 +1,547 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { MediaManager, MediaManagerCallbacks } from './media.manager';
import { PeerData } from '../realtime.types';
interface FakeTrack {
kind: string;
enabled: boolean;
readyState: string;
id: string;
stop: ReturnType<typeof vi.fn>;
getSettings(): { deviceId: string };
}
interface FakeSender {
track: FakeTrack | null;
replaceTrack: ReturnType<typeof vi.fn>;
setStreams: ReturnType<typeof vi.fn>;
}
interface FakeTransceiver {
sender: FakeSender;
receiver: { track: { kind: string } | null };
direction: string;
}
interface FakePeer {
peerData: PeerData;
sender: FakeSender;
addTransceiver: ReturnType<typeof vi.fn>;
removeTrack: ReturnType<typeof vi.fn>;
}
describe('MediaManager microphone switching', () => {
let capturedConstraints: MediaStreamConstraints[];
let getUserMedia: ReturnType<typeof vi.fn>;
beforeEach(() => {
capturedConstraints = [];
getUserMedia = vi.fn(async (constraints: MediaStreamConstraints) => {
capturedConstraints.push(constraints);
const requestedDeviceId = readRequestedDeviceId(constraints);
return createFakeStream(`track-${requestedDeviceId || 'default'}-${capturedConstraints.length}`, requestedDeviceId);
});
vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('captures the preferred microphone when voice starts', async () => {
const { manager } = await startVoice({ preferredInputDeviceId: 'headset' });
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('headset');
manager.destroy();
});
it('swaps the live track into the existing peer sender without renegotiating', async () => {
const { manager, peer, callbacks } = await startVoice();
const previousTrack = peer.sender.track;
callbacks.renegotiate.mockClear();
peer.sender.replaceTrack.mockClear();
await manager.switchInputDevice('headset');
expect(readRequestedDeviceId(capturedConstraints[1])).toBe('headset');
expect(peer.sender.replaceTrack).toHaveBeenCalledTimes(1);
expect(peer.sender.track).not.toBe(previousTrack);
expect(callbacks.renegotiate).not.toHaveBeenCalled();
expect(manager.getIsVoiceActive()).toBe(true);
manager.destroy();
});
it('releases the previous microphone after the swap', async () => {
const { manager, firstStreamTracks } = await startVoice();
await manager.switchInputDevice('headset');
expect(firstStreamTracks[0].stop).toHaveBeenCalled();
manager.destroy();
});
it('keeps the microphone muted across a swap', async () => {
const { manager } = await startVoice();
manager.toggleMute(true);
await manager.switchInputDevice('headset');
expect(manager.getIsMicMuted()).toBe(true);
expect(manager.getLocalStream()?.getAudioTracks()[0].enabled).toBe(false);
manager.destroy();
});
it('renegotiates when the audio transceiver had to be created', async () => {
const { manager, peer, callbacks } = createManager({ withExistingAudioTransceiver: false });
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.setAllowedVoicePeerIds(['peer-1']);
expect(peer.addTransceiver).toHaveBeenCalled();
expect(callbacks.renegotiate).toHaveBeenCalledWith('peer-1');
manager.destroy();
});
it('only records the preference when voice is not active', async () => {
const { manager, callbacks } = createManager();
await expect(manager.switchInputDevice('headset')).resolves.toBeNull();
expect(getUserMedia).not.toHaveBeenCalled();
expect(callbacks.renegotiate).not.toHaveBeenCalled();
await manager.enableVoice();
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('headset');
manager.destroy();
});
// Requiring the device exactly is what makes the picker work, so the price is
// that a vanished device rejects. Voice must survive that, not end.
it('falls back to the system default when the saved device is gone', async () => {
getUserMedia.mockImplementation(async (constraints: MediaStreamConstraints) => {
capturedConstraints.push(constraints);
const requestedDeviceId = readRequestedDeviceId(constraints);
if (requestedDeviceId === 'unplugged-headset') {
throw Object.assign(new Error('device gone'), { name: 'OverconstrainedError' });
}
return createFakeStream(`track-${requestedDeviceId || 'default'}`, requestedDeviceId);
});
const { manager } = createManager();
await manager.switchInputDevice('unplugged-headset');
await expect(manager.enableVoice()).resolves.toBeDefined();
expect(readRequestedDeviceId(capturedConstraints[0])).toBe('unplugged-headset');
expect(readRequestedDeviceId(capturedConstraints[1])).toBe('');
expect(manager.getPreferredInputDeviceId()).toBe('');
manager.destroy();
});
it('does not open another microphone when permission was denied', async () => {
getUserMedia.mockImplementation(async () => {
throw Object.assign(new Error('denied'), { name: 'NotAllowedError' });
});
const { manager } = createManager();
await manager.switchInputDevice('headset');
await expect(manager.enableVoice()).rejects.toThrow('denied');
expect(getUserMedia).toHaveBeenCalledTimes(1);
manager.destroy();
});
});
describe('MediaManager outgoing voice routing', () => {
beforeEach(() => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async () => createFakeStream('track-default', ''))
}
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
// The signal server broadcasts `user_left` for a socket it declared dead, which wipes
// the roster copy of a peer that never left voice. Detaching there left one side of a
// live call permanently silent, with no way back through the UI.
it('keeps sending to a peer the roster forgot mid-call', async () => {
const { manager, peer } = await startVoiceInChannel();
manager.setAllowedVoicePeerIds([]);
expect(peer.removeTrack).not.toHaveBeenCalled();
expect(peer.sender.track).not.toBeNull();
manager.destroy();
});
it('stops sending as soon as the peer itself reports leaving voice', async () => {
const { manager, peer } = await startVoiceInChannel();
manager.setAllowedVoicePeerIds([]);
manager.notePeerVoiceReport('peer-1', { isConnected: false });
expect(peer.removeTrack).toHaveBeenCalled();
manager.destroy();
});
it('stops sending when the peer reports another voice channel', async () => {
const { manager, peer } = await startVoiceInChannel();
manager.setAllowedVoicePeerIds([]);
manager.notePeerVoiceReport('peer-1', {
isConnected: true,
roomId: 'voice-2',
serverId: 'server-1'
});
expect(peer.removeTrack).toHaveBeenCalled();
manager.destroy();
});
// The fail-open half must never turn into "send the microphone on a hunch".
it('never opens a voice path for a peer nothing confirmed', async () => {
const { manager, peer } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
expect(peer.sender.track).toBeNull();
manager.destroy();
});
it('opens a voice path when the peer reports our channel itself', async () => {
const { manager, peer } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
manager.notePeerVoiceReport('peer-1', {
isConnected: true,
roomId: 'voice-1',
serverId: 'server-1'
});
expect(peer.sender.track).not.toBeNull();
manager.destroy();
});
});
// A new peer connection attaches the microphone before its first offer, which spares a
// confirmed channel member a second SDP exchange. That shortcut bypassed routing
// entirely, so being in voice was enough to send the microphone to the next person we
// peered with - and the attached track then read as an established path routing held.
describe('MediaManager first-offer voice gate', () => {
beforeEach(() => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async () => createFakeStream('track-default', ''))
}
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('keeps the microphone off a first offer to a peer nothing confirmed', async () => {
const { manager } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(false);
manager.destroy();
});
it('puts the microphone on a first offer to a confirmed channel member', async () => {
const { manager } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
manager.setAllowedVoicePeerIds(['peer-2']);
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(true);
manager.destroy();
});
it('never puts the microphone on a first offer while we are not in voice', async () => {
const { manager } = createManager();
manager.setAllowedVoicePeerIds(['peer-2']);
expect(manager.mayOpenVoicePathToPeer('peer-2')).toBe(false);
manager.destroy();
});
});
describe('MediaManager camera routing', () => {
beforeEach(() => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async (constraints: MediaStreamConstraints) => constraints.video
? createFakeCameraStream()
: createFakeStream('track-default', ''))
}
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
// The camera is only shared from inside a voice session, so a dead socket wiping the
// roster is the same false departure it is for voice. Cutting the video there left the
// peer watching a frozen tile for the rest of the call.
it('keeps sending camera video to a peer the roster forgot mid-share', async () => {
const { manager, peer } = await startCameraShareInChannel();
manager.setAllowedVoicePeerIds([]);
expect(peer.peerData.videoSender?.track).not.toBeNull();
manager.destroy();
});
// The peer's own word beats the roster, and it has to reach the camera in the same pass:
// refreshing only the microphone left our video going to someone who just left voice.
it('stops sending camera video as soon as the peer reports leaving voice', async () => {
const { manager, peer } = await startCameraShareInChannel();
manager.notePeerVoiceReport('peer-1', { isConnected: false });
expect(peer.peerData.videoSender?.track).toBeNull();
manager.destroy();
});
it('never opens a camera path for a peer nothing confirmed', async () => {
const { manager, peer } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
await manager.enableCamera();
expect(peer.peerData.videoSender?.track ?? null).toBeNull();
manager.destroy();
});
});
async function startCameraShareInChannel(): Promise<{ manager: MediaManager; peer: FakePeer }> {
const { manager, peer } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
manager.setAllowedVoicePeerIds(['peer-1']);
await manager.enableCamera();
expect(peer.peerData.videoSender?.track).not.toBeNull();
return { manager, peer };
}
async function startVoiceInChannel(): Promise<{ manager: MediaManager; peer: FakePeer }> {
const { manager, peer } = createManager();
await manager.toggleNoiseReduction(false);
await manager.enableVoice();
manager.startVoiceHeartbeat('voice-1', 'server-1');
manager.setAllowedVoicePeerIds(['peer-1']);
expect(peer.sender.track).not.toBeNull();
return { manager, peer };
}
function createFakeStream(trackId: string, deviceId: string): MediaStream {
const track: FakeTrack = {
kind: 'audio',
enabled: true,
readyState: 'live',
id: trackId,
stop: vi.fn(),
getSettings: () => ({ deviceId })
};
return {
id: `stream-${trackId}`,
getAudioTracks: () => [track],
getVideoTracks: () => [],
getTracks: () => [track]
} as unknown as MediaStream;
}
function createFakeCameraStream(): MediaStream {
const track: FakeTrack = {
kind: 'video',
enabled: true,
readyState: 'live',
id: 'camera-track',
stop: vi.fn(),
getSettings: () => ({ deviceId: 'camera' })
};
return {
id: 'stream-camera',
getAudioTracks: () => [],
getVideoTracks: () => [track],
getTracks: () => [track]
} as unknown as MediaStream;
}
function readRequestedDeviceId(constraints: MediaStreamConstraints | undefined): string {
const audio = constraints?.audio;
if (!audio || typeof audio === 'boolean') {
return '';
}
const { deviceId } = audio;
if (typeof deviceId === 'string') {
return deviceId;
}
const exact = (deviceId as { exact?: string } | undefined)?.exact;
return typeof exact === 'string' ? exact : '';
}
function createFakePeer(withExistingAudioTransceiver: boolean): FakePeer {
const sender: FakeSender = {
track: null,
replaceTrack: vi.fn(async (track: FakeTrack | null) => {
sender.track = track;
}),
setStreams: vi.fn()
};
const transceivers: FakeTransceiver[] = withExistingAudioTransceiver
? [{ sender, receiver: { track: { kind: 'audio' } }, direction: 'sendrecv' }]
: [];
const addTransceiver = vi.fn((kind: string) => {
const created: FakeTransceiver = {
sender: {
track: null,
replaceTrack: vi.fn(async (track: FakeTrack | null) => {
created.sender.track = track;
}),
setStreams: vi.fn()
},
receiver: { track: { kind } },
direction: 'sendrecv'
};
transceivers.push(created);
return created;
});
const removeTrack = vi.fn((sender: FakeSender) => {
sender.track = null;
});
const connection = {
signalingState: 'stable',
getTransceivers: () => transceivers,
getSenders: () => transceivers.map((transceiver) => transceiver.sender),
addTransceiver,
removeTrack
};
return {
peerData: { connection } as unknown as PeerData,
sender,
addTransceiver,
removeTrack
};
}
function createManager(options: { withExistingAudioTransceiver?: boolean } = {}): {
manager: MediaManager;
peer: FakePeer;
callbacks: { renegotiate: ReturnType<typeof vi.fn> } & MediaManagerCallbacks;
} {
const peer = createFakePeer(options.withExistingAudioTransceiver ?? true);
const callbacks = {
getActivePeers: () => new Map<string, PeerData>([['peer-1', peer.peerData]]),
renegotiate: vi.fn(async () => undefined),
broadcastMessage: vi.fn(),
broadcastIdentityScopedMessage: vi.fn(),
setCameraEnabled: vi.fn()
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
logStream: vi.fn(),
attachTrackDiagnostics: vi.fn()
};
const manager = new MediaManager(logger as never, callbacks);
return { manager, peer, callbacks };
}
async function startVoice(options: {
preferredInputDeviceId?: string;
withExistingAudioTransceiver?: boolean;
} = {}): Promise<{
manager: MediaManager;
peer: FakePeer;
callbacks: { renegotiate: ReturnType<typeof vi.fn> } & MediaManagerCallbacks;
firstStreamTracks: FakeTrack[];
}> {
const { manager, peer, callbacks } = createManager({
withExistingAudioTransceiver: options.withExistingAudioTransceiver
});
await manager.toggleNoiseReduction(false);
if (options.preferredInputDeviceId) {
manager.setPreferredInputDeviceId(options.preferredInputDeviceId);
}
const stream = await manager.enableVoice();
manager.setAllowedVoicePeerIds(['peer-1']);
return {
manager,
peer,
callbacks,
firstStreamTracks: stream.getAudioTracks() as unknown as FakeTrack[]
};
}
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, */ /* eslint-disable @typescript-eslint/no-unused-vars, */
/** /**
* Manages local voice and camera media: getUserMedia, mute, deafen, * Manages local voice and camera media: getUserMedia, mute, deafen,
* attaching/detaching tracks to peer connections, bitrate tuning, * attaching/detaching tracks to peer connections, bitrate tuning,
@@ -11,8 +11,19 @@ import { ChatEvent } from '../../../shared-kernel';
import { LatencyProfile } from '../realtime.constants'; import { LatencyProfile } from '../realtime.constants';
import { PeerData } from '../realtime.types'; import { PeerData } from '../realtime.types';
import { WebRTCLogger } from '../logging/webrtc-logger'; import { WebRTCLogger } from '../logging/webrtc-logger';
import { PeerScopedIdentity } from '../peer-connection-manager/messaging/data-channel';
import { NoiseReductionManager } from './noise-reduction.manager'; import { NoiseReductionManager } from './noise-reduction.manager';
import { loadVoiceSettingsFromStorage } from '../../../domains/voice-session/infrastructure/util/voice-settings-storage.util'; import { loadVoiceSettingsFromStorage } from '../../../domains/voice-session/infrastructure/util/voice-settings-storage.util';
import {
SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
buildMicrophoneConstraints,
isDeviceUnavailableError
} from '../../../domains/voice-session/domain/logic/audio-device-selection.rules';
import {
decideVoicePathRouting,
type PeerVoiceReport,
type VoicePathRouting
} from '../../../domains/voice-session/domain/logic/voice-path-routing.rules';
import { import {
TRACK_KIND_AUDIO, TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO, TRACK_KIND_VIDEO,
@@ -41,9 +52,11 @@ export interface MediaManagerCallbacks {
renegotiate(peerId: string): Promise<void>; renegotiate(peerId: string): Promise<void>;
/** Broadcast a message to all peers. */ /** Broadcast a message to all peers. */
broadcastMessage(event: ChatEvent): void; broadcastMessage(event: ChatEvent): void;
/** Get identify credentials (for broadcasting). */ /**
getIdentifyOderId(): string; * Broadcast a self-identifying message, rebuilt per peer so each peer receives the
getIdentifyDisplayName(): string; * actor id it knows us by on its own signal server.
*/
broadcastIdentityScopedMessage(buildEvent: (identity: PeerScopedIdentity) => ChatEvent): void;
/** Push the current local camera state back into service-level signals. */ /** Push the current local camera state back into service-level signals. */
setCameraEnabled?(enabled: boolean): void; setCameraEnabled?(enabled: boolean): void;
} }
@@ -91,6 +104,13 @@ export class MediaManager {
*/ */
private _noiseReductionDesired = true; private _noiseReductionDesired = true;
/**
* The microphone the user picked, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}.
* Every capture path reads it, so joining voice from the channel list honours
* the same device as joining from the voice controls.
*/
private preferredInputDeviceId = SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
// State tracked locally (the service exposes these via signals) // State tracked locally (the service exposes these via signals)
private isVoiceActive = false; private isVoiceActive = false;
private isMicMuted = false; private isMicMuted = false;
@@ -103,17 +123,26 @@ export class MediaManager {
private currentVoiceServerId: string | undefined; private currentVoiceServerId: string | undefined;
private allowedVoicePeerIds = new Set<string>(); private allowedVoicePeerIds = new Set<string>();
/** When the roster started listing each allowed peer, so a peer's own word can be dated against it. */
private rosterPresenceSince = new Map<string, number>();
/** The last voice membership each peer reported over its data channel. */
private peerVoiceReports = new Map<string, PeerVoiceReport>();
constructor( constructor(
private readonly logger: WebRTCLogger, private readonly logger: WebRTCLogger,
private callbacks: MediaManagerCallbacks private callbacks: MediaManagerCallbacks
) { ) {
this.noiseReduction = new NoiseReductionManager(logger); this.noiseReduction = new NoiseReductionManager(logger);
// Read the persisted noise-reduction preference so enableVoice() // Read the persisted preferences so enableVoice() uses the correct
// uses the correct value even before voice-controls loads. // values even before voice-controls loads.
try { try {
this._noiseReductionDesired = loadVoiceSettingsFromStorage().noiseReduction; const settings = loadVoiceSettingsFromStorage();
} catch { /* keep default */ }
this._noiseReductionDesired = settings.noiseReduction;
this.preferredInputDeviceId = settings.inputDevice;
} catch { /* keep defaults */ }
} }
/** /**
@@ -170,6 +199,19 @@ export class MediaManager {
getIsNoiseReductionEnabled(): boolean { getIsNoiseReductionEnabled(): boolean {
return this._noiseReductionDesired; return this._noiseReductionDesired;
} }
/** The microphone the next capture will request. */
getPreferredInputDeviceId(): string {
return this.preferredInputDeviceId;
}
/**
* Remember which microphone to capture from, without touching a live capture.
*
* @param deviceId - Device id, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID} to follow the system.
*/
setPreferredInputDeviceId(deviceId: string): void {
this.preferredInputDeviceId = deviceId || SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
}
setAllowedVoicePeerIds(peerIds: Iterable<string>): void { setAllowedVoicePeerIds(peerIds: Iterable<string>): void {
const nextAllowed = new Set(peerIds); const nextAllowed = new Set(peerIds);
@@ -179,10 +221,31 @@ export class MediaManager {
} }
this.allowedVoicePeerIds = nextAllowed; this.allowedVoicePeerIds = nextAllowed;
this.trackRosterPresence(nextAllowed);
this.syncVoiceRouting(); this.syncVoiceRouting();
this.syncCameraRouting(); this.syncCameraRouting();
} }
/**
* Record what a peer said about its own voice membership.
*
* This is the only positive evidence that a peer left our channel: the roster
* can lose a peer for reasons that have nothing to do with voice.
*/
notePeerVoiceReport(peerId: string, report: Omit<PeerVoiceReport, 'at'>): void {
this.peerVoiceReports.set(peerId, { ...report, at: Date.now() });
// Camera video is gated on the same report, so it has to answer to it in the same
// pass - a peer that just said it left voice must not keep our camera either.
this.refreshVoiceRouting();
}
/** Forget a peer's voice claims once the peer connection is gone for good. */
forgetPeerVoiceReport(peerId: string): void {
this.peerVoiceReports.delete(peerId);
this.rosterPresenceSince.delete(peerId);
}
refreshVoiceRouting(): void { refreshVoiceRouting(): void {
this.syncVoiceRouting(); this.syncVoiceRouting();
this.syncCameraRouting(); this.syncCameraRouting();
@@ -206,57 +269,118 @@ export class MediaManager {
this.localMediaStream = null; this.localMediaStream = null;
} }
const mediaConstraints: MediaStreamConstraints = { const stream = await this.captureMicrophone();
audio: { const localStream = await this.adoptMicrophoneStream(stream);
echoCancellation: true,
noiseSuppression: !this._noiseReductionDesired,
autoGainControl: true
},
video: false
};
this.logger.info('getUserMedia constraints', mediaConstraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
'navigator.mediaDevices is not available. ' +
'This requires a secure context (HTTPS or localhost). ' +
'If accessing from an external device, use HTTPS.'
);
}
const voicePermissionsGranted = await ensureMobileVoiceCapturePermissions();
if (!voicePermissionsGranted) {
throw new Error('Microphone permission was not granted.');
}
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
this.rawMicStream = stream;
// If the user wants noise reduction, pipe through the denoiser
this.localMediaStream = this._noiseReductionDesired
? await this.noiseReduction.enable(stream)
: stream;
// Apply input gain (mic volume) before sending to peers
await this.applyInputGainToCurrentStream();
this.logger.logStream('localVoice', this.localMediaStream);
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next(); this.voiceConnected$.next();
void startMobileVoiceForegroundSession(); return localStream;
return this.localMediaStream;
} catch (error) { } catch (error) {
this.logger.error('Failed to getUserMedia', error); this.logger.error('Failed to getUserMedia', error);
throw error; throw error;
} }
} }
/**
* Move the live microphone to another device without leaving voice.
*
* The new track is swapped into the existing peer senders, so remote peers
* keep the same audio track and never see a leave/rejoin. The previous
* device is released only after the swap, to avoid a gap of silence.
*
* @param deviceId - Device id, or {@link SYSTEM_DEFAULT_AUDIO_DEVICE_ID}.
* @returns The new local stream, or `null` when voice is not active.
*/
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
this.setPreferredInputDeviceId(deviceId);
if (!this.isVoiceActive) {
return null;
}
const previousRawStream = this.rawMicStream;
const stream = await this.captureMicrophone();
const localStream = await this.adoptMicrophoneStream(stream);
if (previousRawStream && previousRawStream !== stream) {
previousRawStream.getTracks().forEach((track) => track.stop());
}
this.logger.info('Switched microphone', { deviceId: this.preferredInputDeviceId });
return localStream;
}
private async captureMicrophone(): Promise<MediaStream> {
const mediaConstraints = buildMicrophoneConstraints({
deviceId: this.preferredInputDeviceId,
browserNoiseSuppression: !this._noiseReductionDesired
});
this.logger.info('getUserMedia constraints', mediaConstraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
'navigator.mediaDevices is not available. ' +
'This requires a secure context (HTTPS or localhost). ' +
'If accessing from an external device, use HTTPS.'
);
}
const voicePermissionsGranted = await ensureMobileVoiceCapturePermissions();
if (!voicePermissionsGranted) {
throw new Error('Microphone permission was not granted.');
}
try {
return await navigator.mediaDevices.getUserMedia(mediaConstraints);
} catch (error) {
if (!this.preferredInputDeviceId || !isDeviceUnavailableError(error)) {
throw error;
}
// The saved device is gone or already claimed. Requesting it exactly is
// what makes the picker work at all, so the retry - not a looser
// constraint - is what keeps a stale id from ending the call.
this.logger.warn('Saved microphone unavailable, falling back to the system default', {
deviceId: this.preferredInputDeviceId
});
this.preferredInputDeviceId = SYSTEM_DEFAULT_AUDIO_DEVICE_ID;
return await navigator.mediaDevices.getUserMedia(buildMicrophoneConstraints({
deviceId: SYSTEM_DEFAULT_AUDIO_DEVICE_ID,
browserNoiseSuppression: !this._noiseReductionDesired
}));
}
}
/**
* Route a freshly captured microphone stream through the denoiser and gain
* pipeline, honour the current mute state, and bind it to every peer.
*/
private async adoptMicrophoneStream(stream: MediaStream): Promise<MediaStream> {
this.rawMicStream = stream;
// If the user wants noise reduction, pipe through the denoiser
this.localMediaStream = this._noiseReductionDesired
? await this.noiseReduction.enable(stream)
: stream;
// Apply input gain (mic volume) before sending to peers
await this.applyInputGainToCurrentStream();
this.applyCurrentMuteState();
const localStream = this.localMediaStream;
this.logger.logStream('localVoice', localStream);
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
void startMobileVoiceForegroundSession();
return localStream;
}
/** /**
* Stop all local media tracks and remove audio senders from peers. * Stop all local media tracks and remove audio senders from peers.
* The peer connections themselves are kept alive. * The peer connections themselves are kept alive.
@@ -290,6 +414,7 @@ export class MediaManager {
this.currentVoiceRoomId = undefined; this.currentVoiceRoomId = undefined;
this.currentVoiceServerId = undefined; this.currentVoiceServerId = undefined;
this.allowedVoicePeerIds.clear(); this.allowedVoicePeerIds.clear();
this.rosterPresenceSince.clear();
void stopMobileVoiceForegroundSession(); void stopMobileVoiceForegroundSession();
} }
@@ -301,24 +426,17 @@ export class MediaManager {
* denoiser before being sent to peers. * denoiser before being sent to peers.
*/ */
async setLocalStream(stream: MediaStream): Promise<void> { async setLocalStream(stream: MediaStream): Promise<void> {
this.rawMicStream = stream; const previousRawStream = this.rawMicStream;
this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired); this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired);
// Pipe through the denoiser when the user wants noise reduction await this.adoptMicrophoneStream(stream);
if (this._noiseReductionDesired) {
this.logger.info('Piping new stream through noise reduction'); if (previousRawStream && previousRawStream !== stream) {
this.localMediaStream = await this.noiseReduction.enable(stream); previousRawStream.getTracks().forEach((track) => track.stop());
} else {
this.localMediaStream = stream;
} }
// Apply input gain (mic volume) before sending to peers
await this.applyInputGainToCurrentStream();
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next(); this.voiceConnected$.next();
void startMobileVoiceForegroundSession();
} }
/** /**
@@ -615,50 +733,189 @@ export class MediaManager {
const localAudioTrack = localStream?.getAudioTracks()[0] || null; const localAudioTrack = localStream?.getAudioTracks()[0] || null;
peers.forEach((peerData, peerId) => { peers.forEach((peerData, peerId) => {
const didChange = localStream && localAudioTrack && this.allowedVoicePeerIds.has(peerId) const routing = this.decideMicRoutingForPeer(peerId, peerData, localAudioTrack);
if (routing === 'hold') {
this.logger.info('Holding an established voice path without confirmed peer membership', { peerId });
}
const needsRenegotiation = localStream && localAudioTrack && routing !== 'close'
? this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack) ? this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack)
: this.detachVoiceTrackFromPeer(peerData); : this.detachVoiceTrackFromPeer(peerData);
if (didChange) { if (needsRenegotiation) {
void this.callbacks.renegotiate(peerId); void this.callbacks.renegotiate(peerId);
} }
}); });
} }
private decideMicRoutingForPeer(
peerId: string,
peerData: PeerData,
localAudioTrack: MediaStreamTrack | null
): VoicePathRouting {
return decideVoicePathRouting({
hasLocalVoice: this.isVoiceActive && !!localAudioTrack,
localVoiceChannel: this.getLocalVoiceChannel(),
rosterPresenceAt: this.getRosterPresenceAt(peerId),
peerReport: this.peerVoiceReports.get(peerId) ?? null,
hasEstablishedPath: this.hasEstablishedMicPath(peerData),
isPeerConnectionClosed: peerData.connection.connectionState === 'closed'
});
}
/**
* Whether a peer's incoming voice may still be heard.
*
* Playback answers to the same evidence as the microphone: a roster that lost the
* peer is not a reason to mute a peer we are still negotiated with, while a peer
* nothing confirmed is never opened on a guess.
*/
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
return decideVoicePathRouting({
hasLocalVoice: this.isVoiceActive,
localVoiceChannel: this.getLocalVoiceChannel(),
rosterPresenceAt: this.getRosterPresenceAt(peerId),
peerReport: this.peerVoiceReports.get(peerId) ?? null,
hasEstablishedPath: hasEstablishedPlayback,
isPeerConnectionClosed: false
}) !== 'close';
}
/**
* Whether a peer connection being built may carry our microphone in its first offer.
*
* Attaching at creation time is what spares a confirmed channel member a second SDP
* exchange, but the first offer opens a path like any other, so it answers to the same
* evidence. Attaching on a guess would also be self-perpetuating: the track alone makes
* the next routing pass read an established path and hold it.
*/
mayOpenVoicePathToPeer(peerId: string): boolean {
return decideVoicePathRouting({
hasLocalVoice: this.isVoiceActive,
localVoiceChannel: this.getLocalVoiceChannel(),
rosterPresenceAt: this.getRosterPresenceAt(peerId),
peerReport: this.peerVoiceReports.get(peerId) ?? null,
hasEstablishedPath: false,
isPeerConnectionClosed: false
}) === 'open';
}
private getLocalVoiceChannel(): { roomId?: string; serverId?: string } {
return {
roomId: this.currentVoiceRoomId,
serverId: this.currentVoiceServerId
};
}
private getRosterPresenceAt(peerId: string): number | null {
return this.allowedVoicePeerIds.has(peerId)
? this.rosterPresenceSince.get(peerId) ?? 0
: null;
}
/** Whether our microphone is already on a negotiated sender for this peer. */
private hasEstablishedMicPath(peerData: PeerData): boolean {
const audioSender = peerData.audioSender
?? peerData.connection
.getSenders()
.find((sender) => sender !== peerData.screenAudioSender && sender.track?.kind === TRACK_KIND_AUDIO);
return !!audioSender?.track;
}
/**
* Camera video answers to the same evidence as the microphone.
*
* The camera is only ever shared from inside a voice session (see {@link enableCamera}),
* so a roster that lost a peer mid-share is the same false departure it is for voice -
* and cutting the video there left the peer looking at a frozen tile for the rest of
* the call.
*/
private decideCameraRoutingForPeer(peerId: string, peerData: PeerData): VoicePathRouting {
return decideVoicePathRouting({
hasLocalVoice: this.isVoiceActive,
localVoiceChannel: this.getLocalVoiceChannel(),
rosterPresenceAt: this.getRosterPresenceAt(peerId),
peerReport: this.peerVoiceReports.get(peerId) ?? null,
hasEstablishedPath: this.hasEstablishedCameraPath(peerData),
isPeerConnectionClosed: peerData.connection.connectionState === 'closed'
});
}
/** Whether our camera is already on a negotiated sender for this peer. */
private hasEstablishedCameraPath(peerData: PeerData): boolean {
const videoSender = peerData.videoSender
?? peerData.connection
.getSenders()
.find((sender) => sender !== peerData.screenVideoSender && sender.track?.kind === TRACK_KIND_VIDEO);
return !!videoSender?.track;
}
private trackRosterPresence(nextAllowed: Set<string>): void {
const now = Date.now();
for (const peerId of nextAllowed) {
if (!this.rosterPresenceSince.has(peerId)) {
this.rosterPresenceSince.set(peerId, now);
}
}
for (const peerId of this.rosterPresenceSince.keys()) {
if (!nextAllowed.has(peerId)) {
this.rosterPresenceSince.delete(peerId);
}
}
}
private syncCameraRouting(): void { private syncCameraRouting(): void {
const peers = this.callbacks.getActivePeers(); const peers = this.callbacks.getActivePeers();
const localCameraStream = this.localCameraStream; const localCameraStream = this.localCameraStream;
const localCameraTrack = localCameraStream?.getVideoTracks()[0] || null; const localCameraTrack = localCameraStream?.getVideoTracks()[0] || null;
peers.forEach((peerData, peerId) => { peers.forEach((peerData, peerId) => {
const didChange = localCameraStream && localCameraTrack && this.allowedVoicePeerIds.has(peerId) const routing = this.decideCameraRoutingForPeer(peerId, peerData);
const needsRenegotiation = localCameraStream && localCameraTrack && routing !== 'close'
? this.attachCameraTrackToPeer(peerId, peerData, localCameraStream, localCameraTrack) ? this.attachCameraTrackToPeer(peerId, peerData, localCameraStream, localCameraTrack)
: this.detachCameraTrackFromPeer(peerData, peerId); : this.detachCameraTrackFromPeer(peerData, peerId);
if (didChange) { if (needsRenegotiation) {
void this.callbacks.renegotiate(peerId); void this.callbacks.renegotiate(peerId);
} }
}); });
} }
/**
* Bind the local audio track to one peer.
*
* @returns Whether the change needs SDP renegotiation. A plain track swap on
* an already negotiated sender does not: that is what keeps a live
* microphone change from disturbing the session.
*/
private attachVoiceTrackToPeer( private attachVoiceTrackToPeer(
peerId: string, peerId: string,
peerData: PeerData, peerData: PeerData,
localStream: MediaStream, localStream: MediaStream,
localAudioTrack: MediaStreamTrack localAudioTrack: MediaStreamTrack
): boolean { ): boolean {
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, { const { transceiver: audioTransceiver, created } = this.getOrCreateReusableTransceiver(
preferredSender: peerData.audioSender, peerData,
excludedSenders: [peerData.screenAudioSender] TRACK_KIND_AUDIO,
}); {
preferredSender: peerData.audioSender,
excludedSenders: [peerData.screenAudioSender]
}
);
const audioSender = audioTransceiver.sender; const audioSender = audioTransceiver.sender;
const needsDirectionRestore = audioTransceiver.direction === TRANSCEIVER_RECV_ONLY const needsDirectionRestore = audioTransceiver.direction === TRANSCEIVER_RECV_ONLY
|| audioTransceiver.direction === TRANSCEIVER_INACTIVE; || audioTransceiver.direction === TRANSCEIVER_INACTIVE;
const needsTrackReplace = audioSender.track !== localAudioTrack; const needsTrackReplace = audioSender.track !== localAudioTrack;
const needsRenegotiation = created || needsDirectionRestore;
peerData.audioSender = audioSender; peerData.audioSender = audioSender;
if (!needsDirectionRestore && !needsTrackReplace) { if (!needsRenegotiation && !needsTrackReplace) {
return false; return false;
} }
@@ -666,7 +923,9 @@ export class MediaManager {
audioTransceiver.direction = TRANSCEIVER_SEND_RECV; audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
} }
if (typeof audioSender.setStreams === 'function') { // setStreams rewrites the msid, which only reaches the peer through SDP.
// Skip it on a plain swap so the swap stays renegotiation-free.
if (needsRenegotiation && typeof audioSender.setStreams === 'function') {
audioSender.setStreams(localStream); audioSender.setStreams(localStream);
} }
@@ -677,7 +936,7 @@ export class MediaManager {
.catch((error) => this.logger.error('audio replaceTrack failed', error)); .catch((error) => this.logger.error('audio replaceTrack failed', error));
} }
return true; return needsRenegotiation;
} }
private detachVoiceTrackFromPeer(peerData: PeerData): boolean { private detachVoiceTrackFromPeer(peerData: PeerData): boolean {
@@ -692,24 +951,30 @@ export class MediaManager {
return true; return true;
} }
/** @returns Whether the change needs SDP renegotiation (see {@link attachVoiceTrackToPeer}). */
private attachCameraTrackToPeer( private attachCameraTrackToPeer(
peerId: string, peerId: string,
peerData: PeerData, peerData: PeerData,
localStream: MediaStream, localStream: MediaStream,
localCameraTrack: MediaStreamTrack localCameraTrack: MediaStreamTrack
): boolean { ): boolean {
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, { const { transceiver: videoTransceiver, created } = this.getOrCreateReusableTransceiver(
preferredSender: peerData.videoSender, peerData,
excludedSenders: [peerData.screenVideoSender] TRACK_KIND_VIDEO,
}); {
preferredSender: peerData.videoSender,
excludedSenders: [peerData.screenVideoSender]
}
);
const videoSender = videoTransceiver.sender; const videoSender = videoTransceiver.sender;
const needsDirectionRestore = videoTransceiver.direction === TRANSCEIVER_RECV_ONLY const needsDirectionRestore = videoTransceiver.direction === TRANSCEIVER_RECV_ONLY
|| videoTransceiver.direction === TRANSCEIVER_INACTIVE; || videoTransceiver.direction === TRANSCEIVER_INACTIVE;
const needsTrackReplace = videoSender.track !== localCameraTrack; const needsTrackReplace = videoSender.track !== localCameraTrack;
const needsRenegotiation = created || needsDirectionRestore;
peerData.videoSender = videoSender; peerData.videoSender = videoSender;
if (!needsDirectionRestore && !needsTrackReplace) { if (!needsRenegotiation && !needsTrackReplace) {
return false; return false;
} }
@@ -717,7 +982,7 @@ export class MediaManager {
videoTransceiver.direction = TRANSCEIVER_SEND_RECV; videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
} }
if (typeof videoSender.setStreams === 'function') { if (needsRenegotiation && typeof videoSender.setStreams === 'function') {
videoSender.setStreams(localStream); videoSender.setStreams(localStream);
} }
@@ -728,7 +993,7 @@ export class MediaManager {
.catch((error) => this.logger.error('camera replaceTrack failed', error)); .catch((error) => this.logger.error('camera replaceTrack failed', error));
} }
return true; return needsRenegotiation;
} }
private detachCameraTrackFromPeer(peerData: PeerData, peerId: string): boolean { private detachCameraTrackFromPeer(peerData: PeerData, peerId: string): boolean {
@@ -778,6 +1043,12 @@ export class MediaManager {
return true; return true;
} }
/**
* Find a transceiver to send `kind` on, creating one only as a last resort.
*
* `created` tells the caller whether the peer needs a new SDP exchange: a
* transceiver the remote has never seen cannot carry media until it does.
*/
private getOrCreateReusableTransceiver( private getOrCreateReusableTransceiver(
peerData: PeerData, peerData: PeerData,
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO, kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,
@@ -785,7 +1056,7 @@ export class MediaManager {
preferredSender?: RTCRtpSender; preferredSender?: RTCRtpSender;
excludedSenders?: (RTCRtpSender | undefined)[]; excludedSenders?: (RTCRtpSender | undefined)[];
} }
): RTCRtpTransceiver { ): { transceiver: RTCRtpTransceiver; created: boolean } {
const excludedSenders = new Set( const excludedSenders = new Set(
(options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender) (options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender)
); );
@@ -795,7 +1066,7 @@ export class MediaManager {
: null; : null;
if (preferredTransceiver) { if (preferredTransceiver) {
return preferredTransceiver; return { transceiver: preferredTransceiver, created: false };
} }
const attachedSenderTransceiver = existingTransceivers.find((transceiver) => const attachedSenderTransceiver = existingTransceivers.find((transceiver) =>
@@ -804,7 +1075,7 @@ export class MediaManager {
); );
if (attachedSenderTransceiver) { if (attachedSenderTransceiver) {
return attachedSenderTransceiver; return { transceiver: attachedSenderTransceiver, created: false };
} }
const reusableReceiverTransceiver = existingTransceivers.find((transceiver) => const reusableReceiverTransceiver = existingTransceivers.find((transceiver) =>
@@ -814,20 +1085,20 @@ export class MediaManager {
); );
if (reusableReceiverTransceiver) { if (reusableReceiverTransceiver) {
return reusableReceiverTransceiver; return { transceiver: reusableReceiverTransceiver, created: false };
} }
return peerData.connection.addTransceiver(kind, { return {
direction: TRANSCEIVER_SEND_RECV transceiver: peerData.connection.addTransceiver(kind, {
}); direction: TRANSCEIVER_SEND_RECV
}),
created: true
};
} }
/** Broadcast a voice-presence state event to all connected peers. */ /** Broadcast a voice-presence state event to all connected peers. */
private broadcastVoicePresence(): void { private broadcastVoicePresence(): void {
const oderId = this.callbacks.getIdentifyOderId(); this.callbacks.broadcastIdentityScopedMessage(({ displayName, oderId }) => ({
const displayName = this.callbacks.getIdentifyDisplayName();
this.callbacks.broadcastMessage({
type: P2P_TYPE_VOICE_STATE, type: P2P_TYPE_VOICE_STATE,
oderId, oderId,
displayName, displayName,
@@ -838,20 +1109,17 @@ export class MediaManager {
roomId: this.currentVoiceRoomId, roomId: this.currentVoiceRoomId,
serverId: this.currentVoiceServerId serverId: this.currentVoiceServerId
} }
}); }));
} }
/** Broadcast the local camera state to all connected peers. */ /** Broadcast the local camera state to all connected peers. */
private broadcastCameraState(): void { private broadcastCameraState(): void {
const oderId = this.callbacks.getIdentifyOderId(); this.callbacks.broadcastIdentityScopedMessage(({ displayName, oderId }) => ({
const displayName = this.callbacks.getIdentifyDisplayName();
this.callbacks.broadcastMessage({
type: P2P_TYPE_CAMERA_STATE, type: P2P_TYPE_CAMERA_STATE,
oderId, oderId,
displayName, displayName,
isCameraEnabled: this.isCameraActive isCameraEnabled: this.isCameraActive
}); }));
} }
// -- Input gain helpers -- // -- Input gain helpers --
@@ -59,6 +59,13 @@ export class VoiceSessionController {
this.syncMediaSignals(); this.syncMediaSignals();
} }
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
const stream = await this.dependencies.mediaManager.switchInputDevice(deviceId);
this.syncMediaSignals();
return stream;
}
toggleMute(muted?: boolean): void { toggleMute(muted?: boolean): void {
this.dependencies.mediaManager.toggleMute(muted); this.dependencies.mediaManager.toggleMute(muted);
this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted()); this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted());
@@ -0,0 +1,171 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { Subject } from 'rxjs';
import { createPeerConnection } from './create-peer-connection';
import {
ConnectionLifecycleHandlers,
PeerConnectionManagerContext,
createPeerConnectionManagerState
} from '../shared';
interface FakeSender {
track: MediaStreamTrack | null;
replaceTrack: ReturnType<typeof vi.fn>;
setStreams: ReturnType<typeof vi.fn>;
}
interface FakeTransceiver {
sender: FakeSender;
receiver: { track: { kind: string } };
direction: string;
}
interface FakeConnection {
transceivers: FakeTransceiver[];
addTrack: ReturnType<typeof vi.fn>;
}
/**
* A peer connection carries chat and presence for everyone in the server, not only the
* people in our voice channel, and the initiator attaches the microphone before the very
* first offer. Being in voice therefore used to be enough to start sending the microphone
* to whoever we peered with next - and because a routing pass reads an attached track as
* an established path, it then held that path open instead of closing it.
*/
describe('createPeerConnection microphone gate', () => {
let connection: FakeConnection;
let micTrack: MediaStreamTrack;
let micStream: MediaStream;
beforeEach(() => {
connection = createFakeConnection();
({ micStream, micTrack } = createFakeMicStream());
vi.stubGlobal('RTCPeerConnection', vi.fn(function FakeRTCPeerConnection() {
return connection;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('leaves the microphone off the first offer to a peer nothing confirms', () => {
const context = createContext({ micStream, mayOpenVoicePath: false });
const peerData = createPeerConnection(context, 'peer-1', true, createHandlers());
expect(context.callbacks.mayOpenVoicePathToPeer).toHaveBeenCalledWith('peer-1');
expect(peerData.audioSender?.track ?? null).toBeNull();
expect(connection.addTrack).not.toHaveBeenCalled();
});
it('puts the microphone on the first offer to a confirmed channel member', () => {
const context = createContext({ micStream, mayOpenVoicePath: true });
const peerData = createPeerConnection(context, 'peer-1', true, createHandlers());
expect(peerData.audioSender?.track).toBe(micTrack);
});
});
function createFakeConnection(): FakeConnection {
const transceivers: FakeTransceiver[] = [];
const fake = {
connectionState: 'new',
iceConnectionState: 'new',
signalingState: 'stable',
transceivers,
addTrack: vi.fn(),
addTransceiver: vi.fn((kind: string, init?: { direction?: string }) => {
const transceiver: FakeTransceiver = {
sender: {
track: null,
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
transceiver.sender.track = track;
}),
setStreams: vi.fn()
},
receiver: { track: { kind } },
direction: init?.direction ?? 'sendrecv'
};
transceivers.push(transceiver);
return transceiver;
}),
createDataChannel: vi.fn(() => ({ label: 'data', readyState: 'connecting' })),
getSenders: () => transceivers.map((transceiver) => transceiver.sender),
getTransceivers: () => transceivers
};
return fake as unknown as FakeConnection;
}
function createFakeMicStream(): { micStream: MediaStream; micTrack: MediaStreamTrack } {
const micTrack = { kind: 'audio', id: 'mic-track' } as unknown as MediaStreamTrack;
const micStream = {
id: 'mic-stream',
getTracks: () => [micTrack],
getAudioTracks: () => [micTrack],
getVideoTracks: () => []
} as unknown as MediaStream;
return { micStream, micTrack };
}
function createContext(options: {
micStream: MediaStream;
mayOpenVoicePath: boolean;
}): PeerConnectionManagerContext {
return {
logger: {
error: vi.fn(),
info: vi.fn(),
logStream: vi.fn(),
warn: vi.fn()
} as unknown as PeerConnectionManagerContext['logger'],
callbacks: {
getIceServers: vi.fn(() => []),
getIdentifyCredentials: vi.fn(() => null),
getIdentifyCredentialsForPeer: vi.fn(() => null),
getLocalMediaStream: vi.fn(() => options.micStream),
getLocalPeerId: vi.fn(() => 'local-peer'),
getVoiceStateSnapshot: vi.fn(() => ({
isConnected: true,
isMuted: false,
isDeafened: false,
isScreenSharing: false,
roomId: 'voice-1',
serverId: 'server-1'
})),
isCameraEnabled: vi.fn(() => false),
isScreenSharingActive: vi.fn(() => false),
isSignalingConnected: vi.fn(() => true),
mayOpenVoicePathToPeer: vi.fn(() => options.mayOpenVoicePath),
sendRawMessage: vi.fn()
},
state: {
...createPeerConnectionManagerState(),
peerConnected$: new Subject<string>()
}
};
}
function createHandlers(): ConnectionLifecycleHandlers {
return {
addToConnectedPeers: vi.fn(),
clearPeerDisconnectGraceTimer: vi.fn(),
clearPeerReconnectTimer: vi.fn(),
handleRemoteTrack: vi.fn(),
removePeer: vi.fn(),
requestVoiceStateFromPeer: vi.fn(),
schedulePeerDisconnectRecovery: vi.fn(),
schedulePeerReconnect: vi.fn(),
setupDataChannel: vi.fn(),
trackDisconnectedPeer: vi.fn()
};
}
@@ -188,9 +188,22 @@ export function createPeerConnection(
const localStream = callbacks.getLocalMediaStream(); const localStream = callbacks.getLocalMediaStream();
if (localStream && isInitiator) { if (localStream && isInitiator) {
const mayCarryVoice = callbacks.mayOpenVoicePathToPeer(remotePeerId);
logger.logStream(`localStream->${remotePeerId}`, localStream); logger.logStream(`localStream->${remotePeerId}`, localStream);
localStream.getTracks().forEach((track) => { localStream.getTracks().forEach((track) => {
// Being in voice ourselves is not a reason to send the microphone to whoever we
// happen to peer with next: this connection also carries chat and presence for
// people who are not in the channel. Routing attaches it once something confirms.
if (track.kind === TRACK_KIND_AUDIO && !mayCarryVoice) {
logger.info('Leaving the microphone off the first offer for an unconfirmed voice peer', {
remotePeerId
});
return;
}
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
if (typeof peerData.audioSender.setStreams === 'function') { if (typeof peerData.audioSender.setStreams === 'function') {
peerData.audioSender.setStreams(localStream); peerData.audioSender.setStreams(localStream);
@@ -5,6 +5,7 @@ import {
TRACK_KIND_VIDEO, TRACK_KIND_VIDEO,
TRANSCEIVER_SEND_RECV TRANSCEIVER_SEND_RECV
} from '../../realtime.constants'; } from '../../realtime.constants';
import { isPoliteOnOfferCollision } from '../../peer-role.rules';
import { import {
NegotiationHandlers, NegotiationHandlers,
PeerConnectionManagerContext, PeerConnectionManagerContext,
@@ -115,8 +116,8 @@ async function resolveOfferCollision(
if (!hasCollision) if (!hasCollision)
return true; return true;
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null; const localOderId = callbacks.getIdentifyCredentialsForPeer(fromUserId)?.oderId ?? null;
const isPolite = !localOderId || localOderId > fromUserId; const isPolite = isPoliteOnOfferCollision(localOderId, fromUserId);
if (!isPolite) { if (!isPolite) {
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId }); logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localOderId });
@@ -26,6 +26,29 @@ describe('data channel lifecycle', () => {
expect(handlers.scheduleDataChannelRecovery).not.toHaveBeenCalled(); expect(handlers.scheduleDataChannelRecovery).not.toHaveBeenCalled();
}); });
it('announces the actor id the peer knows us by on its own signal server', () => {
const context = createContext();
const handlers = createHandlers();
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
setupDataChannel(context, channel, 'peer-a', handlers);
channel.onopen?.(new Event('open'));
expect(channel.send).toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user-foreign"'));
expect(channel.send).not.toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user"'));
});
it('falls back to the home identity when the peer signal route is unknown', () => {
const context = createContext();
const handlers = createHandlers();
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
setupDataChannel(context, channel, 'peer-unrouted', handlers);
channel.onopen?.(new Event('open'));
expect(channel.send).toHaveBeenCalledWith(expect.stringContaining('"oderId":"local-user"'));
});
it('requests a voice-state resync on a non-fatal data channel error', () => { it('requests a voice-state resync on a non-fatal data channel error', () => {
const context = createContext(); const context = createContext();
const handlers = createHandlers(); const handlers = createHandlers();
@@ -74,8 +97,12 @@ function createContext(): PeerConnectionManagerContext {
callbacks: { callbacks: {
getIceServers: vi.fn(() => []), getIceServers: vi.fn(() => []),
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', token: 'session-token', displayName: 'Local User' })), getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', token: 'session-token', displayName: 'Local User' })),
getIdentifyCredentialsForPeer: vi.fn((peerId: string) => peerId === 'peer-a'
? { displayName: 'Local User', oderId: 'local-user-foreign', token: 'foreign-token' }
: null),
getLocalMediaStream: vi.fn(() => null), getLocalMediaStream: vi.fn(() => null),
getLocalPeerId: vi.fn(() => 'local-peer'), getLocalPeerId: vi.fn(() => 'local-peer'),
mayOpenVoicePathToPeer: vi.fn(() => true),
getVoiceStateSnapshot: vi.fn(() => ({ getVoiceStateSnapshot: vi.fn(() => ({
isConnected: true, isConnected: true,
isMuted: false, isMuted: false,
@@ -21,6 +21,11 @@ type PeerMessage = Record<string, unknown> & {
ts?: number; ts?: number;
}; };
export interface PeerScopedIdentity {
oderId: string;
displayName: string;
}
/** /**
* Wire open/close/error/message handlers onto a data channel. * Wire open/close/error/message handlers onto a data channel.
*/ */
@@ -239,18 +244,22 @@ export function broadcastMessage(
/** /**
* Send a ChatEvent to a specific peer's data channel. * Send a ChatEvent to a specific peer's data channel.
*
* Returns whether the payload actually reached the channel. Callers that
* reason about peer state - sync rounds, call rings - must not treat a
* closed channel or a failed send as a delivery.
*/ */
export function sendToPeer( export function sendToPeer(
context: PeerConnectionManagerContext, context: PeerConnectionManagerContext,
peerId: string, peerId: string,
event: object event: object
): void { ): boolean {
const { logger, state } = context; const { logger, state } = context;
const peerData = state.activePeerConnections.get(peerId); const peerData = state.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
logger.warn('Peer not connected - cannot send', { peerId }); logger.warn('Peer not connected - cannot send', { peerId });
return; return false;
} }
try { try {
@@ -260,6 +269,8 @@ export function sendToPeer(
recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound'); recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound');
logDataChannelTraffic(context, peerData.dataChannel, peerId, 'outbound', rawPayload, event as PeerMessage); logDataChannelTraffic(context, peerData.dataChannel, peerId, 'outbound', rawPayload, event as PeerMessage);
return true;
} catch (error) { } catch (error) {
logger.error('[data-channel] Failed to send message to peer', error, { logger.error('[data-channel] Failed to send message to peer', error, {
bufferedAmount: peerData.dataChannel.bufferedAmount, bufferedAmount: peerData.dataChannel.bufferedAmount,
@@ -268,6 +279,8 @@ export function sendToPeer(
peerId, peerId,
readyState: peerData.dataChannel.readyState readyState: peerData.dataChannel.readyState
}); });
return false;
} }
} }
@@ -339,9 +352,7 @@ export function sendCurrentStatesToPeer(
peerId: string peerId: string
): void { ): void {
const { callbacks } = context; const { callbacks } = context;
const credentials = callbacks.getIdentifyCredentials(); const { displayName, oderId } = resolvePeerScopedIdentity(context, peerId);
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = callbacks.getVoiceStateSnapshot(); const voiceState = callbacks.getVoiceStateSnapshot();
sendToPeer(context, peerId, { sendToPeer(context, peerId, {
@@ -382,9 +393,7 @@ export function sendCurrentStatesToChannel(
return; return;
} }
const credentials = callbacks.getIdentifyCredentials(); const { displayName, oderId } = resolvePeerScopedIdentity(context, remotePeerId);
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = callbacks.getVoiceStateSnapshot(); const voiceState = callbacks.getVoiceStateSnapshot();
try { try {
@@ -431,31 +440,50 @@ export function sendCurrentStatesToChannel(
/** Broadcast the current voice, camera, and screen-share states to all connected peers. */ /** Broadcast the current voice, camera, and screen-share states to all connected peers. */
export function broadcastCurrentStates(context: PeerConnectionManagerContext): void { export function broadcastCurrentStates(context: PeerConnectionManagerContext): void {
forEachOpenPeerChannel(context, (peerId) => sendCurrentStatesToPeer(context, peerId));
}
/**
* Broadcast a self-identifying payload, rebuilt per peer so each one receives the actor
* id it knows us by on its own signal server.
*/
export function broadcastIdentityScopedMessage(
context: PeerConnectionManagerContext,
buildEvent: (identity: PeerScopedIdentity) => object
): void {
forEachOpenPeerChannel(context, (peerId) => {
sendToPeer(context, peerId, buildEvent(resolvePeerScopedIdentity(context, peerId)));
});
}
/**
* The identity to announce to one peer. Voice, camera, and screen-share payloads are keyed
* by the id that peer knows us by, which is our actor id on the signal server routing them
* - not our home id.
*/
function resolvePeerScopedIdentity(
context: PeerConnectionManagerContext,
peerId: string
): PeerScopedIdentity {
const { callbacks } = context; const { callbacks } = context;
const credentials = callbacks.getIdentifyCredentials(); const credentials = callbacks.getIdentifyCredentialsForPeer(peerId) ?? callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = callbacks.getVoiceStateSnapshot();
broadcastMessage(context, { return {
type: P2P_TYPE_VOICE_STATE, displayName: credentials?.displayName || DEFAULT_DISPLAY_NAME,
oderId, oderId: credentials?.oderId || callbacks.getLocalPeerId()
displayName, };
voiceState }
});
broadcastMessage(context, { function forEachOpenPeerChannel(
type: P2P_TYPE_SCREEN_STATE, context: PeerConnectionManagerContext,
oderId, send: (peerId: string) => void
displayName, ): void {
isScreenSharing: callbacks.isScreenSharingActive() context.state.activePeerConnections.forEach((peerData, peerId) => {
}); if (peerData.dataChannel?.readyState !== DATA_CHANNEL_STATE_OPEN) {
return;
}
broadcastMessage(context, { send(peerId);
type: P2P_TYPE_CAMERA_STATE,
oderId,
displayName,
isCameraEnabled: callbacks.isCameraEnabled()
}); });
} }
@@ -5,6 +5,7 @@ import {
DisconnectedPeerEntry, DisconnectedPeerEntry,
IdentifyCredentials, IdentifyCredentials,
PeerData, PeerData,
PeerRecoveryStatusEvent,
VoiceStateSnapshot VoiceStateSnapshot
} from '../realtime.types'; } from '../realtime.types';
@@ -17,12 +18,24 @@ export interface PeerConnectionCallbacks {
sendRawMessage(msg: Record<string, unknown>): void; sendRawMessage(msg: Record<string, unknown>): void;
/** Get the current local media stream (mic audio). */ /** Get the current local media stream (mic audio). */
getLocalMediaStream(): MediaStream | null; getLocalMediaStream(): MediaStream | null;
/**
* Whether a peer connection being built may carry the local microphone in its first
* offer, or whether nothing yet confirms the peer is in our voice channel.
*/
mayOpenVoicePathToPeer(peerId: string): boolean;
/** Whether signaling is currently connected. */ /** Whether signaling is currently connected. */
isSignalingConnected(): boolean; isSignalingConnected(): boolean;
/** Returns the current voice/screen state snapshot for broadcasting. */ /** Returns the current voice/screen state snapshot for broadcasting. */
getVoiceStateSnapshot(): VoiceStateSnapshot; getVoiceStateSnapshot(): VoiceStateSnapshot;
/** Returns the identify credentials (oderId + displayName). */ /** Returns the identify credentials (oderId + displayName). */
getIdentifyCredentials(): IdentifyCredentials | null; getIdentifyCredentials(): IdentifyCredentials | null;
/**
* Returns the identify credentials in the identity space of the signal server that
* routes this peer, or null when that peer's signal route is unknown. Peer ids come
* from a per-signal roster, so role election and self-identifying payloads must use
* this rather than the home credential.
*/
getIdentifyCredentialsForPeer(peerId: string): IdentifyCredentials | null;
/** Returns the local peer ID. */ /** Returns the local peer ID. */
getLocalPeerId(): string; getLocalPeerId(): string;
/** Whether screen sharing is active. */ /** Whether screen sharing is active. */
@@ -50,6 +63,7 @@ export interface PeerConnectionManagerState {
peerNegotiationQueue: Map<string, Promise<void>>; peerNegotiationQueue: Map<string, Promise<void>>;
peerConnected$: Subject<string>; peerConnected$: Subject<string>;
peerDisconnected$: Subject<string>; peerDisconnected$: Subject<string>;
peerRecoveryStatus$: Subject<PeerRecoveryStatusEvent>;
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>; remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
messageReceived$: Subject<ChatEvent>; messageReceived$: Subject<ChatEvent>;
connectedPeersChanged$: Subject<string[]>; connectedPeersChanged$: Subject<string[]>;
@@ -113,6 +127,7 @@ export function createPeerConnectionManagerState(): PeerConnectionManagerState {
peerNegotiationQueue: new Map<string, Promise<void>>(), peerNegotiationQueue: new Map<string, Promise<void>>(),
peerConnected$: new Subject<string>(), peerConnected$: new Subject<string>(),
peerDisconnected$: new Subject<string>(), peerDisconnected$: new Subject<string>(),
peerRecoveryStatus$: new Subject<PeerRecoveryStatusEvent>(),
remoteStream$: new Subject<{ peerId: string; stream: MediaStream }>(), remoteStream$: new Subject<{ peerId: string; stream: MediaStream }>(),
messageReceived$: new Subject<ChatEvent>(), messageReceived$: new Subject<ChatEvent>(),
connectedPeersChanged$: new Subject<string[]>(), connectedPeersChanged$: new Subject<string[]>(),
@@ -11,7 +11,7 @@
* This file wires them together and exposes a public API that is * This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change. * identical to the old monolithic service so consumers don't change.
*/ */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { import {
Injectable, Injectable,
inject, inject,
@@ -37,8 +37,12 @@ import { MediaManager } from './media/media.manager';
import { ScreenShareManager } from './media/screen-share.manager'; import { ScreenShareManager } from './media/screen-share.manager';
import { VoiceSessionController } from './media/voice-session-controller'; import { VoiceSessionController } from './media/voice-session-controller';
import { IceServerSettingsService } from './ice-server-settings.service'; import { IceServerSettingsService } from './ice-server-settings.service';
import type { PeerData, VoiceStateSnapshot } from './realtime.types'; import type {
import { LatencyProfile } from './realtime.constants'; PeerData,
PeerRecoveryStatusEvent,
VoiceStateSnapshot
} from './realtime.types';
import { LatencyProfile, P2P_TYPE_VOICE_STATE } from './realtime.constants';
import { ScreenShareStartOptions } from './screen-share.config'; import { ScreenShareStartOptions } from './screen-share.config';
import { WebRTCLogger } from './logging/webrtc-logger'; import { WebRTCLogger } from './logging/webrtc-logger';
import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager'; import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager';
@@ -114,6 +118,10 @@ export class WebRTCService implements OnDestroy {
get onVoiceConnected(): Observable<void> { get onVoiceConnected(): Observable<void> {
return this.peerMediaFacade.onVoiceConnected; return this.peerMediaFacade.onVoiceConnected;
} }
/** Peer recovery gave up on a peer, or re-armed it after signaling returned. */
get onPeerRecoveryStatus(): Observable<PeerRecoveryStatusEvent> {
return this.peerManager.peerRecoveryStatus$.asObservable();
}
private readonly peerManager: PeerConnectionManager; private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager; private readonly mediaManager: MediaManager;
@@ -199,9 +207,13 @@ export class WebRTCService implements OnDestroy {
this.peerManager.setCallbacks({ this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingTransportHandler.sendRawMessage(msg), sendRawMessage: (msg: Record<string, unknown>) => this.signalingTransportHandler.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(), getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(),
mayOpenVoicePathToPeer: (peerId: string): boolean =>
this.peerMediaFacade.mayOpenVoicePathToPeer(peerId),
isSignalingConnected: (): boolean => this.state.isSignalingConnected(), isSignalingConnected: (): boolean => this.state.isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(), getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(),
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(), getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
getIdentifyCredentialsForPeer: (peerId: string) =>
this.signalingTransportHandler.getIdentifyCredentialsForPeer(peerId),
getLocalPeerId: (): string => this.state.getLocalPeerId(), getLocalPeerId: (): string => this.state.getLocalPeerId(),
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(), isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(), isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
@@ -212,8 +224,8 @@ export class WebRTCService implements OnDestroy {
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(), getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId), renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event), broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event),
getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(), broadcastIdentityScopedMessage: (buildEvent): void =>
getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName(), this.peerMediaFacade.broadcastIdentityScopedMessage(buildEvent),
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled) setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
}); });
@@ -230,7 +242,8 @@ export class WebRTCService implements OnDestroy {
}); });
this.signalingMessageHandler = new IncomingSignalingMessageHandler({ this.signalingMessageHandler = new IncomingSignalingMessageHandler({
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null, getLocalOderIdForSignalUrl: (signalUrl: string) =>
this.signalingTransportHandler.getLocalOderIdForSignalUrl(signalUrl),
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId), getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
isVoiceConnected: () => this.state.isVoiceConnectedActive(), isVoiceConnected: () => this.state.isVoiceConnectedActive(),
peerManager: this.peerManager, peerManager: this.peerManager,
@@ -262,9 +275,10 @@ export class WebRTCService implements OnDestroy {
private wireManagerEvents(): void { private wireManagerEvents(): void {
// Internal control-plane messages for on-demand screen-share delivery. // Internal control-plane messages for on-demand screen-share delivery.
this.peerManager.messageReceived$.subscribe((event) => this.peerManager.messageReceived$.subscribe((event) => {
this.remoteScreenShareRequestController.handlePeerControlMessage(event) this.remoteScreenShareRequestController.handlePeerControlMessage(event);
); this.notePeerVoiceReport(event);
});
// Peer manager -> connected peers signal // Peer manager -> connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
@@ -285,6 +299,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.peerDisconnected$.subscribe((peerId) => { this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId); this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
this.mediaManager.forgetPeerVoiceReport(peerId);
}); });
// Media manager -> voice connected signal // Media manager -> voice connected signal
@@ -298,6 +313,31 @@ export class WebRTCService implements OnDestroy {
); );
} }
/**
* A peer's own `voice-state` message is the only evidence that can prove it left
* our voice channel. The roster can lose a peer for reasons that have nothing to
* do with voice - a dead socket makes the signal server broadcast `user_left` -
* and that must never cut a negotiated media path.
*/
private notePeerVoiceReport(event: ChatEvent): void {
if (event.type !== P2P_TYPE_VOICE_STATE) {
return;
}
const peerId = event.fromPeerId;
const voiceState = event.voiceState;
if (!peerId || !voiceState) {
return;
}
this.mediaManager.notePeerVoiceReport(peerId, {
isConnected: voiceState.isConnected ?? false,
roomId: voiceState.roomId,
serverId: voiceState.serverId
});
}
private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void { private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void {
this.state.updateSignalingConnectionStatus( this.state.updateSignalingConnectionStatus(
connected ? true : this.signalingCoordinator.isAnySignalingConnected(), connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
@@ -306,6 +346,8 @@ export class WebRTCService implements OnDestroy {
); );
if (connected) { if (connected) {
// Peers whose reconnect budget ran out during the outage stay dead until re-armed.
this.peerManager.resumeStalledPeerRecovery();
this.signalingReconnectedSubject$.next(signalUrl); this.signalingReconnectedSubject$.next(signalUrl);
} }
} }
@@ -503,9 +545,10 @@ export class WebRTCService implements OnDestroy {
* *
* @param peerId - The target peer ID. * @param peerId - The target peer ID.
* @param event - The chat event to send. * @param event - The chat event to send.
* @returns whether the event reached the peer's open data channel.
*/ */
sendToPeer(peerId: string, event: ChatEvent): void { sendToPeer(peerId: string, event: ChatEvent): boolean {
this.peerMediaFacade.sendToPeer(peerId, event); return this.peerMediaFacade.sendToPeer(peerId, event);
} }
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
@@ -653,6 +696,16 @@ export class WebRTCService implements OnDestroy {
await this.voiceSessionController.setLocalStream(stream); await this.voiceSessionController.setLocalStream(stream);
} }
/**
* Move the live microphone to another device without leaving voice.
*
* @param deviceId - Device id, or an empty string to follow the system default.
* @returns The new local stream, or `null` when voice is not active.
*/
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
return await this.voiceSessionController.switchInputDevice(deviceId);
}
/** /**
* Toggle the local microphone mute state. * Toggle the local microphone mute state.
* *
@@ -746,6 +799,14 @@ export class WebRTCService implements OnDestroy {
this.mediaManager.setAllowedVoicePeerIds(allowedPeerIds); this.mediaManager.setAllowedVoicePeerIds(allowedPeerIds);
} }
/**
* Whether a peer's incoming voice may be heard, judged on the same evidence as the
* outgoing microphone rather than on the roster alone.
*/
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
return this.mediaManager.mayHearPeerVoice(peerId, hasEstablishedPlayback);
}
/** /**
* Start sharing the screen (or a window) with all connected peers. * Start sharing the screen (or a window) with all connected peers.
* *
@@ -3,6 +3,7 @@ import { ChatEvent } from '../../../shared-kernel';
import { ScreenShareStartOptions } from '../screen-share.config'; import { ScreenShareStartOptions } from '../screen-share.config';
import { PeerData } from '../realtime.types'; import { PeerData } from '../realtime.types';
import { MediaManager } from '../media/media.manager'; import { MediaManager } from '../media/media.manager';
import { PeerScopedIdentity } from '../peer-connection-manager/messaging/data-channel';
import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager'; import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager';
import { ScreenShareManager } from '../media/screen-share.manager'; import { ScreenShareManager } from '../media/screen-share.manager';
@@ -49,8 +50,12 @@ export class PeerMediaFacade {
this.dependencies.peerManager.broadcastMessage(event); this.dependencies.peerManager.broadcastMessage(event);
} }
sendToPeer(peerId: string, event: ChatEvent): void { broadcastIdentityScopedMessage(buildEvent: (identity: PeerScopedIdentity) => ChatEvent): void {
this.dependencies.peerManager.sendToPeer(peerId, event); this.dependencies.peerManager.broadcastIdentityScopedMessage(buildEvent);
}
sendToPeer(peerId: string, event: ChatEvent): boolean {
return this.dependencies.peerManager.sendToPeer(peerId, event);
} }
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> { async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
@@ -101,6 +106,10 @@ export class PeerMediaFacade {
return this.dependencies.mediaManager.getRawMicStream(); return this.dependencies.mediaManager.getRawMicStream();
} }
mayOpenVoicePathToPeer(peerId: string): boolean {
return this.dependencies.mediaManager.mayOpenVoicePathToPeer(peerId);
}
isScreenShareActive(): boolean { isScreenShareActive(): boolean {
return this.dependencies.screenShareManager.getIsScreenActive(); return this.dependencies.screenShareManager.getIsScreenActive();
} }