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
@@ -11,7 +11,7 @@
* This file wires them together and exposes a public API that is
* 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 {
Injectable,
inject,
@@ -37,8 +37,12 @@ import { MediaManager } from './media/media.manager';
import { ScreenShareManager } from './media/screen-share.manager';
import { VoiceSessionController } from './media/voice-session-controller';
import { IceServerSettingsService } from './ice-server-settings.service';
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
import { LatencyProfile } from './realtime.constants';
import type {
PeerData,
PeerRecoveryStatusEvent,
VoiceStateSnapshot
} from './realtime.types';
import { LatencyProfile, P2P_TYPE_VOICE_STATE } from './realtime.constants';
import { ScreenShareStartOptions } from './screen-share.config';
import { WebRTCLogger } from './logging/webrtc-logger';
import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager';
@@ -114,6 +118,10 @@ export class WebRTCService implements OnDestroy {
get onVoiceConnected(): Observable<void> {
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 mediaManager: MediaManager;
@@ -199,9 +207,13 @@ export class WebRTCService implements OnDestroy {
this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingTransportHandler.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(),
mayOpenVoicePathToPeer: (peerId: string): boolean =>
this.peerMediaFacade.mayOpenVoicePathToPeer(peerId),
isSignalingConnected: (): boolean => this.state.isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(),
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
getIdentifyCredentialsForPeer: (peerId: string) =>
this.signalingTransportHandler.getIdentifyCredentialsForPeer(peerId),
getLocalPeerId: (): string => this.state.getLocalPeerId(),
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
@@ -212,8 +224,8 @@ export class WebRTCService implements OnDestroy {
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event),
getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(),
getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName(),
broadcastIdentityScopedMessage: (buildEvent): void =>
this.peerMediaFacade.broadcastIdentityScopedMessage(buildEvent),
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
});
@@ -230,7 +242,8 @@ export class WebRTCService implements OnDestroy {
});
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
getLocalOderIdForSignalUrl: (signalUrl: string) =>
this.signalingTransportHandler.getLocalOderIdForSignalUrl(signalUrl),
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
peerManager: this.peerManager,
@@ -262,9 +275,10 @@ export class WebRTCService implements OnDestroy {
private wireManagerEvents(): void {
// Internal control-plane messages for on-demand screen-share delivery.
this.peerManager.messageReceived$.subscribe((event) =>
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
);
this.peerManager.messageReceived$.subscribe((event) => {
this.remoteScreenShareRequestController.handlePeerControlMessage(event);
this.notePeerVoiceReport(event);
});
// Peer manager -> connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
@@ -285,6 +299,7 @@ export class WebRTCService implements OnDestroy {
this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
this.mediaManager.forgetPeerVoiceReport(peerId);
});
// 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 {
this.state.updateSignalingConnectionStatus(
connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
@@ -306,6 +346,8 @@ export class WebRTCService implements OnDestroy {
);
if (connected) {
// Peers whose reconnect budget ran out during the outage stay dead until re-armed.
this.peerManager.resumeStalledPeerRecovery();
this.signalingReconnectedSubject$.next(signalUrl);
}
}
@@ -503,9 +545,10 @@ export class WebRTCService implements OnDestroy {
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
* @returns whether the event reached the peer's open data channel.
*/
sendToPeer(peerId: string, event: ChatEvent): void {
this.peerMediaFacade.sendToPeer(peerId, event);
sendToPeer(peerId: string, event: ChatEvent): boolean {
return this.peerMediaFacade.sendToPeer(peerId, event);
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
@@ -653,6 +696,16 @@ export class WebRTCService implements OnDestroy {
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.
*
@@ -746,6 +799,14 @@ export class WebRTCService implements OnDestroy {
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.
*