Files
Toju/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts
T
myxelium 92c2f578e2 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.
2026-08-14 03:19:29 +02:00

912 lines
33 KiB
TypeScript

/**
* WebRTCService - thin Angular service that composes specialised managers.
*
* Each concern lives in its own file under `./`:
* - SignalingManager - WebSocket lifecycle & reconnection
* - PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* - MediaManager - mic voice, mute, deafen, bitrate
* - ScreenShareManager - screen capture & mixed audio
* - WebRTCLogger - debug / diagnostic logging
*
* 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/no-non-null-assertion */
import {
Injectable,
inject,
OnDestroy
} from '@angular/core';
import {
Observable,
Subject,
merge
} from 'rxjs';
import { ChatEvent } from '../../shared-kernel';
import type { SignalingMessage } from '../../shared-kernel';
import {
isRelayableAccountSyncEvent,
shouldApplyAccountSyncPayload,
unwrapAccountSyncPayload,
type AccountSyncSignalingMessage
} from './account-sync/account-sync.rules';
import { TimeSyncService } from '../../core/services/time-sync.service';
import { DebuggingService } from '../../core/services/debugging';
import { ScreenShareSourcePickerService } from '../../domains/screen-share';
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,
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';
import { PeerMediaFacade } from './streams/peer-media-facade';
import { RemoteScreenShareRequestController } from './streams/remote-screen-share-request-controller';
import { IncomingSignalingMessage, IncomingSignalingMessageHandler } from './signaling/signaling-message-handler';
import { ServerMembershipSignalingHandler } from './signaling/server-membership-signaling-handler';
import { ServerSignalingCoordinator } from './signaling/server-signaling-coordinator';
import { SignalingManager } from './signaling/signaling.manager';
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
import { WebRtcStateController } from './state/webrtc-state-controller';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
import { Store } from '@ngrx/store';
import { UsersActions } from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
@Injectable({
providedIn: 'root'
})
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly store = inject(Store);
private readonly clientInstance = inject(ClientInstanceService);
private currentHomeUser: { id: string; homeSignalServerUrl?: string; displayName: string } | null = null;
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
readonly peerId = this.state.peerId;
readonly isConnected = this.state.isConnected;
readonly hasEverConnected = this.state.hasEverConnected;
readonly isVoiceConnected = this.state.isVoiceConnected;
readonly connectedPeers = this.state.connectedPeers;
readonly isMuted = this.state.isMuted;
readonly isDeafened = this.state.isDeafened;
readonly isCameraEnabled = this.state.isCameraEnabled;
readonly isScreenSharing = this.state.isScreenSharing;
readonly isNoiseReductionEnabled = this.state.isNoiseReductionEnabled;
readonly screenStream = this.state.screenStream;
readonly isScreenShareRemotePlaybackSuppressed = this.state.isScreenShareRemotePlaybackSuppressed;
readonly forceDefaultRemotePlaybackOutput = this.state.forceDefaultRemotePlaybackOutput;
readonly hasConnectionError = this.state.hasConnectionError;
readonly connectionErrorMessage = this.state.connectionErrorMessage;
readonly shouldShowConnectionError = this.state.shouldShowConnectionError;
readonly peerLatencies = this.state.peerLatencies;
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
private readonly accountSyncRelay$ = new Subject<ChatEvent>();
private readonly signalingReconnectedSubject$ = new Subject<string>();
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> {
return merge(this.peerMediaFacade.onMessageReceived, this.accountSyncRelay$);
}
get onPeerConnected(): Observable<string> {
return this.peerMediaFacade.onPeerConnected;
}
get onPeerDisconnected(): Observable<string> {
return this.peerMediaFacade.onPeerDisconnected;
}
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> {
return this.peerMediaFacade.onRemoteStream;
}
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;
private readonly screenShareManager: ScreenShareManager;
private readonly peerMediaFacade: PeerMediaFacade;
private readonly voiceSessionController: VoiceSessionController;
private readonly signalingCoordinator: ServerSignalingCoordinator<IncomingSignalingMessage>;
private readonly signalingTransportHandler: SignalingTransportHandler<IncomingSignalingMessage>;
private readonly signalingMessageHandler: IncomingSignalingMessageHandler;
private readonly serverMembershipSignalingHandler: ServerMembershipSignalingHandler<IncomingSignalingMessage>;
private readonly remoteScreenShareRequestController: RemoteScreenShareRequestController;
constructor() {
this.store.select(selectCurrentUser).subscribe((user) => {
this.currentHomeUser = user
? {
id: user.id,
homeSignalServerUrl: user.homeSignalServerUrl,
displayName: user.displayName
}
: null;
});
// Create managers with null callbacks first to break circular initialization
this.peerManager = new PeerConnectionManager(this.logger, null!);
this.mediaManager = new MediaManager(this.logger, null!);
this.screenShareManager = new ScreenShareManager(this.logger, null!);
this.peerMediaFacade = new PeerMediaFacade({
peerManager: this.peerManager,
mediaManager: this.mediaManager,
screenShareManager: this.screenShareManager
});
this.voiceSessionController = new VoiceSessionController({
mediaManager: this.mediaManager,
getIsScreenSharing: () => this.state.isScreenSharingActive(),
setVoiceConnected: (connected) => this.state.setVoiceConnected(connected),
setMuted: (muted) => this.state.setMuted(muted),
setDeafened: (deafened) => this.state.setDeafened(deafened),
setNoiseReductionEnabled: (enabled) => this.state.setNoiseReductionEnabled(enabled)
});
this.signalingCoordinator = new ServerSignalingCoordinator({
createManager: (signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager(
this.logger,
() => this.signalingTransportHandler.getIdentifyCredentialsForSignalUrl(signalUrl),
getLastJoinedServer,
getMemberServerIds
),
handleConnectionStatus: (signalUrl, connected, errorMessage) =>
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage),
handleHeartbeatTick: () => this.peerMediaFacade.broadcastCurrentStates(),
handleMessage: (message, signalUrl) => this.handleSignalingMessage(message, signalUrl)
});
this.signalingTransportHandler = new SignalingTransportHandler({
signalingCoordinator: this.signalingCoordinator,
logger: this.logger,
getLocalPeerId: () => this.state.getLocalPeerId(),
resolveCredential: (signalUrl) => {
if (!signalUrl) {
return null;
}
return this.signalServerAuth.resolveCredentialForSignalUrl(signalUrl, this.currentHomeUser);
},
getHomeCredential: () => {
const homeSignalServerUrl = this.currentHomeUser?.homeSignalServerUrl;
if (!homeSignalServerUrl) {
return null;
}
return this.signalServerAuth.resolveCredentialForSignalUrl(homeSignalServerUrl, this.currentHomeUser);
},
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
});
// Now wire up cross-references (all managers are instantiated)
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(),
getIceServers: (): RTCIceServer[] => this.iceServerSettings.rtcIceServers()
});
this.mediaManager.setCallbacks({
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event),
broadcastIdentityScopedMessage: (buildEvent): void =>
this.peerMediaFacade.broadcastIdentityScopedMessage(buildEvent),
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
});
this.screenShareManager.setCallbacks({
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerMediaFacade.broadcastCurrentStates(),
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
sources,
options.includeSystemAudio
),
updateLocalScreenShareState: (state): void => this.state.applyLocalScreenShareState(state)
});
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
getLocalOderIdForSignalUrl: (signalUrl: string) =>
this.signalingTransportHandler.getLocalOderIdForSignalUrl(signalUrl),
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
peerManager: this.peerManager,
setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime),
signalingCoordinator: this.signalingCoordinator,
logger: this.logger
});
this.serverMembershipSignalingHandler = new ServerMembershipSignalingHandler({
signalingCoordinator: this.signalingCoordinator,
signalingTransport: this.signalingTransportHandler,
logger: this.logger,
getActiveServerId: () => this.state.currentServerId,
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
runFullCleanup: () => this.fullCleanup()
});
this.remoteScreenShareRequestController = new RemoteScreenShareRequestController({
getConnectedPeerIds: () => this.peerMediaFacade.getConnectedPeerIds(),
sendToPeer: (peerId, event) => this.peerMediaFacade.sendToPeer(peerId, event),
clearRemoteScreenShareStream: (peerId) => this.peerMediaFacade.clearRemoteScreenShareStream(peerId),
requestScreenShareForPeer: (peerId) => this.peerMediaFacade.requestScreenShareForPeer(peerId),
stopScreenShareForPeer: (peerId) => this.peerMediaFacade.stopScreenShareForPeer(peerId),
clearScreenShareRequest: (peerId) => this.peerMediaFacade.clearScreenShareRequest(peerId)
});
this.wireManagerEvents();
}
private wireManagerEvents(): void {
// Internal control-plane messages for on-demand screen-share delivery.
this.peerManager.messageReceived$.subscribe((event) => {
this.remoteScreenShareRequestController.handlePeerControlMessage(event);
this.notePeerVoiceReport(event);
});
// Peer manager -> connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
this.state.setConnectedPeers(peers)
);
// If we are already sharing when a new peer connection finishes, push the
// current screen-share tracks to that peer and renegotiate.
this.peerManager.peerConnected$.subscribe((peerId) => {
if (this.peerMediaFacade.isScreenShareActive()) {
this.peerMediaFacade.syncScreenShareToPeer(peerId);
}
this.mediaManager.refreshVoiceRouting();
this.remoteScreenShareRequestController.handlePeerConnected(peerId);
});
this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
this.mediaManager.forgetPeerVoiceReport(peerId);
});
// Media manager -> voice connected signal
this.mediaManager.voiceConnected$.subscribe(() => {
this.voiceSessionController.handleVoiceConnected();
});
// Peer manager -> latency updates
this.peerManager.peerLatencyChanged$.subscribe(() =>
this.state.syncPeerLatencies(this.peerManager.peerLatencies)
);
}
/**
* 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(),
connected,
errorMessage
);
if (connected) {
// Peers whose reconnect budget ran out during the outage stay dead until re-armed.
this.peerManager.resumeStalledPeerRecovery();
this.signalingReconnectedSubject$.next(signalUrl);
}
}
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
if (message.type === 'auth_required' || message.type === 'auth_error') {
this.store.dispatch(UsersActions.signalServerAuthFailed({
signalUrl,
reason: message.type
}));
return;
}
if (message.type === 'account_sync') {
const accountMessage = message as AccountSyncSignalingMessage;
if (shouldApplyAccountSyncPayload(
accountMessage.clientInstanceId,
this.clientInstance.getClientInstanceId()
)) {
this.accountSyncRelay$.next(unwrapAccountSyncPayload(accountMessage));
}
return;
}
this.signalingMessage$.next(message);
this.signalingMessageHandler.handleMessage(message, signalUrl);
}
// PUBLIC API - matches the old monolithic service's interface
/**
* Connect to a signaling server via WebSocket.
*
* @param serverUrl - The WebSocket URL of the signaling server.
* @returns An observable that emits `true` once connected.
*/
connectToSignalingServer(serverUrl: string): Observable<boolean> {
return this.signalingTransportHandler.connectToSignalingServer(serverUrl);
}
/** Returns true when the signaling socket for a given URL is currently open. */
isSignalingConnectedTo(serverUrl: string): boolean {
return this.signalingTransportHandler.isSignalingConnectedTo(serverUrl);
}
/**
* Ensure the signaling WebSocket is connected, reconnecting if needed.
*
* @param timeoutMs - Maximum time (ms) to wait for the connection.
* @returns `true` if connected within the timeout.
*/
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return await this.signalingTransportHandler.ensureSignalingConnected(timeoutMs);
}
/**
* Send a signaling-level message (with `from` and `timestamp` auto-populated).
*
* @param message - The signaling message payload (excluding `from` / `timestamp`).
*/
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
this.signalingTransportHandler.sendSignalingMessage(message);
}
/**
* Send a raw JSON payload through the signaling WebSocket.
*
* @param message - Arbitrary JSON message.
*/
sendRawMessage(message: Record<string, unknown>): void {
this.signalingTransportHandler.sendRawMessage(message);
}
/** Send a raw JSON payload through a specific signaling WebSocket. */
sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
return this.signalingTransportHandler.sendRawMessageToSignalUrl(signalUrl, message);
}
/**
* Track the currently-active server ID (for server-scoped operations).
*
* @param serverId - The server to mark as active.
*/
setCurrentServer(serverId: string): void {
this.state.setCurrentServer(serverId);
}
/** The server ID currently being viewed / active, or `null`. */
get currentServerId(): string | null {
return this.state.currentServerId;
}
/** The last signaling URL used by the client, if any. */
getCurrentSignalingUrl(): string | null {
return this.signalingTransportHandler.getCurrentSignalingUrl(this.state.currentServerId);
}
/**
* Send an identify message to the signaling server.
*
* The credentials are cached so they can be replayed after a reconnect.
*
* @param oderId - The user's unique order/peer ID.
* @param displayName - The user's display name.
*/
identify(
oderId: string,
displayName: string,
signalUrl?: string,
profile?: { description?: string; profileUpdatedAt?: number; homeSignalServerUrl?: string }
): void {
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
}
/**
* Join a server (room) on the signaling server.
*
* @param roomId - The server / room ID to join.
* @param userId - The local user ID.
*/
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
this.serverMembershipSignalingHandler.joinRoom(roomId, userId, signalUrl);
}
/**
* Switch to a different server. If already a member, sends a view event;
* otherwise joins the server.
*
* @param serverId - The target server ID.
* @param userId - The local user ID.
*/
switchServer(serverId: string, userId: string, signalUrl?: string): void {
this.serverMembershipSignalingHandler.switchServer(serverId, userId, signalUrl);
}
/**
* Leave one or all servers.
*
* If `serverId` is provided, leaves only that server.
* Otherwise leaves every joined server and performs a full cleanup.
*
* @param serverId - Optional server to leave; omit to leave all.
*/
leaveRoom(serverId?: string): void {
this.serverMembershipSignalingHandler.leaveRoom(serverId);
}
/**
* Check whether the local client has joined a given server.
*
* @param serverId - The server to check.
*/
hasJoinedServer(serverId: string): boolean {
return this.signalingCoordinator.hasJoinedServer(serverId);
}
/** Returns a read-only set of all currently-joined server IDs. */
getJoinedServerIds(): ReadonlySet<string> {
return this.signalingCoordinator.getJoinedServerIds();
}
/**
* Broadcast a {@link ChatEvent} to every connected peer.
*
* @param event - The chat event to send.
*/
broadcastMessage(event: ChatEvent): void {
this.peerMediaFacade.broadcastMessage(event);
this.relayBroadcastEvent(event);
}
/** Relay account-owned state to the user's other connected devices. */
relayAccountSync(event: ChatEvent): void {
if (!isRelayableAccountSyncEvent(event)) {
return;
}
const clientInstanceId = this.clientInstance.getClientInstanceId();
this.signalingTransportHandler.sendRawMessage({
type: 'account_sync',
clientInstanceId,
payload: {
...event,
clientInstanceId
}
});
}
/**
* Send a {@link ChatEvent} to a specific peer.
*
* @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): boolean {
return this.peerMediaFacade.sendToPeer(peerId, event);
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
this.remoteScreenShareRequestController.syncRemoteScreenShareRequests(peerIds, enabled);
}
/**
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
return await this.peerMediaFacade.sendToPeerBuffered(peerId, event);
}
/** Returns an array of currently-connected peer IDs. */
getConnectedPeers(): string[] {
return this.peerMediaFacade.getConnectedPeerIds();
}
hasSignalingRouteForPeer(peerId: string): boolean {
const signalUrl = this.signalingCoordinator.getPeerSignalUrl(peerId);
return !!signalUrl && this.signalingCoordinator.isSignalingConnectedTo(signalUrl);
}
/**
* Get the composite remote {@link MediaStream} for a connected peer.
*
* @param peerId - The remote peer whose stream to retrieve.
* @returns The stream, or `null` if the peer has no active stream.
*/
getRemoteStream(peerId: string): MediaStream | null {
return this.peerMediaFacade.getRemoteStream(peerId);
}
/**
* Get the remote voice-only stream for a connected peer.
*
* @param peerId - The remote peer whose voice stream to retrieve.
* @returns The stream, or `null` if the peer has no active voice audio.
*/
getRemoteVoiceStream(peerId: string): MediaStream | null {
return this.peerMediaFacade.getRemoteVoiceStream(peerId);
}
/**
* Get the remote camera stream for a connected peer.
*
* @param peerId - The remote peer whose camera stream to retrieve.
* @returns The stream, or `null` if the peer has no active camera video.
*/
getRemoteCameraStream(peerId: string): MediaStream | null {
return this.peerMediaFacade.getRemoteCameraStream(peerId);
}
/**
* Get the remote screen-share stream for a connected peer.
*
* This contains the screen video track and any audio track that belongs to
* the screen share itself, not the peer's normal voice-chat audio.
*
* @param peerId - The remote peer whose screen-share stream to retrieve.
* @returns The stream, or `null` if the peer has no active screen share.
*/
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.peerMediaFacade.getRemoteScreenShareStream(peerId);
}
/**
* Get the current local media stream (microphone audio).
*
* @returns The local {@link MediaStream}, or `null` if voice is not active.
*/
getLocalStream(): MediaStream | null {
return this.peerMediaFacade.getLocalStream();
}
/**
* Get the current local camera stream.
*
* @returns The local camera {@link MediaStream}, or `null` if the camera is disabled.
*/
getLocalCameraStream(): MediaStream | null {
return this.peerMediaFacade.getLocalCameraStream();
}
/**
* Get the raw local microphone stream before gain / RNNoise processing.
*
* @returns The raw microphone {@link MediaStream}, or `null` if voice is not active.
*/
getRawMicStream(): MediaStream | null {
return this.peerMediaFacade.getRawMicStream();
}
reportConnectionError(message: string): void {
this.state.setConnectionError(message);
}
clearConnectionError(): void {
this.state.clearConnectionError();
}
/**
* Request microphone access and start sending audio to all peers.
*
* @returns The captured local {@link MediaStream}.
*/
async enableVoice(): Promise<MediaStream> {
return await this.voiceSessionController.enableVoice();
}
/** Stop local voice capture and remove audio senders from peers. */
disableVoice(): void {
this.voiceSessionController.disableVoice();
this.state.setCameraEnabled(false);
}
/**
* Start sharing the local camera video with peers in the active voice channel.
*
* @returns The camera {@link MediaStream}.
*/
async enableCamera(): Promise<MediaStream> {
const stream = await this.mediaManager.enableCamera();
this.state.setCameraEnabled(this.mediaManager.getIsCameraActive());
return stream;
}
/** Stop local camera capture and remove camera tracks from peers. */
disableCamera(): void {
this.mediaManager.disableCamera();
this.state.setCameraEnabled(this.mediaManager.getIsCameraActive());
}
/**
* Inject an externally-obtained media stream as the local voice source.
*
* @param stream - The media stream to use.
*/
async setLocalStream(stream: MediaStream): Promise<void> {
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.
*
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
this.voiceSessionController.toggleMute(muted);
}
/**
* Toggle self-deafen (suppress incoming audio playback).
*
* @param deafened - Explicit state; if omitted, the current state is toggled.
*/
toggleDeafen(deafened?: boolean): void {
this.voiceSessionController.toggleDeafen(deafened);
}
/**
* Toggle RNNoise noise reduction on the local microphone.
*
* When enabled, the raw mic audio is routed through an AudioWorklet
* that applies neural-network noise suppression before being sent
* to peers.
*
* @param enabled - Explicit state; if omitted, the current state is toggled.
*/
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
await this.voiceSessionController.toggleNoiseReduction(enabled);
}
/**
* Set the output volume for remote audio playback.
*
* @param volume - Normalised volume (0-1).
*/
setOutputVolume(volume: number): void {
this.voiceSessionController.setOutputVolume(volume);
}
/**
* Set the input (microphone) volume.
*
* Adjusts a Web Audio GainNode on the local mic stream so the level
* sent to peers changes in real time without renegotiation.
*
* @param volume - Normalised volume (0-1).
*/
setInputVolume(volume: number): void {
this.voiceSessionController.setInputVolume(volume);
}
/**
* Set the maximum audio bitrate for all peer connections.
*
* @param kbps - Target bitrate in kilobits per second.
*/
async setAudioBitrate(kbps: number): Promise<void> {
return await this.voiceSessionController.setAudioBitrate(kbps);
}
/**
* Apply a predefined latency profile that maps to a specific bitrate.
*
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
*/
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
return await this.voiceSessionController.setLatencyProfile(profile);
}
/**
* Start broadcasting voice-presence heartbeats to all peers.
*
* Also marks the given server as the active voice server and closes
* any peer connections that belong to other servers so that audio
* is isolated to the correct voice channel.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.voiceSessionController.startVoiceHeartbeat(roomId, serverId);
}
/** Stop the voice-presence heartbeat. */
stopVoiceHeartbeat(): void {
this.voiceSessionController.stopVoiceHeartbeat();
}
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
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.
*
* @param options - Screen-share capture options.
* @returns The screen-capture {@link MediaStream}.
*/
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.peerMediaFacade.startScreenShare(options);
}
/** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void {
this.peerMediaFacade.stopScreenShare();
}
private relayBroadcastEvent(event: ChatEvent): void {
const clientInstanceId = this.clientInstance.getClientInstanceId();
if (event.type === 'chat-message' && event.message?.roomId) {
this.signalingTransportHandler.sendRawMessage({
type: 'chat_message',
serverId: event.message.roomId,
message: {
...event.message,
clientInstanceId
},
clientInstanceId
});
this.relayAccountSync({
...event,
message: {
...event.message,
clientInstanceId
},
clientInstanceId
});
return;
}
if (event.type === 'voice-state' && event.voiceState?.serverId) {
this.signalingTransportHandler.sendRawMessage({
...event,
type: 'voice_state',
serverId: event.voiceState.serverId,
voiceState: {
...event.voiceState,
clientInstanceId
},
clientInstanceId
});
return;
}
this.relayAccountSync(event);
}
requestVoiceClientTakeover(): void {
this.signalingTransportHandler.sendRawMessage({
type: 'voice_client_takeover',
clientInstanceId: this.clientInstance.getClientInstanceId()
});
}
getClientInstanceId(): string {
return this.clientInstance.getClientInstanceId();
}
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.leaveRoom();
this.destroyAllSignalingManagers();
this.state.resetConnectionState();
}
/** Alias for {@link disconnect}. */
disconnectAll(): void {
this.disconnect();
}
private fullCleanup(): void {
this.signalingCoordinator.clearPeerTracking();
this.remoteScreenShareRequestController.clear();
this.peerMediaFacade.closeAllPeers();
this.state.clearPeerViewState();
this.voiceSessionController.resetVoiceSession();
this.state.setCameraEnabled(false);
this.peerMediaFacade.stopScreenShare();
this.state.clearScreenShareState();
}
private destroyAllSignalingManagers(): void {
this.signalingCoordinator.destroy();
}
ngOnDestroy(): void {
this.disconnect();
this.peerMediaFacade.destroy();
}
}