# Voice & WebRTC Signaling > **Area:** voice-signaling > **Status:** Active > **Last updated:** 2026-05-25 ## Overview Voice and screen-share in Toju are pure WebRTC mesh: peers establish RTCPeerConnections directly, while the signaling server only forwards SDP and ICE messages. This area covers the end-to-end flow — envelope routing, peer election, RTCPeerConnection lifecycle, RNNoise denoising, and the relationships between the three product-client domains involved: `voice-session`, `voice-connection`, and `direct-call`. Screen-share rides on the same peer connection; its UI orchestration is its own domain but the signaling path is shared. ## Responsibilities - Negotiate WebRTC sessions between peers using `offer` / `answer` / `ice_candidate` envelopes forwarded by the signaling server. - Elect an initiator deterministically when multiple peers arrive simultaneously, with a non-initiator fallback timer. - Maintain the local audio pipeline: mic capture → optional RNNoise denoising → RTCPeerConnection sender. - Track per-peer playback gain, mute, deafen, and speaking-activity state on the receive side. - Mirror voice presence (`voice_state`) and direct-call signalling (`direct-call`) to other peers via the WebSocket. This area does **not** own: - The WebSocket envelope shape (see [websocket-envelopes](./websocket-envelopes.md)). - Screen-share UI orchestration (its own domain at `toju-app/src/app/domains/screen-share/`); only the peer connection plumbing is shared. - Persistent user settings beyond `voiceSettingsStorage` (audio device IDs, volumes, bitrate, latency profile, noise-reduction toggle, persisted to localStorage). ## Key concepts - **Mesh** — every participant holds an `RTCPeerConnection` per other participant. No SFU / MCU. - **Voice session** — high-level "user is currently in voice room X" state. Owned by `voice-session` domain. - **Voice connection** — low-level transport/peer concerns: speaking detection, per-peer gain, mute / deafen state. Owned by `voice-connection` domain. - **Direct call** — 1:1 voice/video call with an optional group-upgrade path. Owned by `direct-call` domain. - **Initiator** — the peer responsible for sending the first `offer`. Elected first-peer-wins; non-initiators wait `NON_INITIATOR_GIVE_UP_MS` (≈5 s) before generating their own offer. - **Data channel** — `chat`-labelled data channel established alongside each peer connection for P2P chat fallback and direct-message delivery. - **Noise suppressor worklet** — RNNoise WASM running in an `AudioWorkletNode` (`NoiseSuppressorWorklet`), loaded from `rnnoise-worklet.js` at the app root. --- ## Signaling envelopes (consumed) Defined in [websocket-envelopes](./websocket-envelopes.md). Voice-relevant types: - `offer`, `answer`, `ice_candidate` — forwarded by the server to `targetUserId` without inspection. - `direct-call` — forwarded; payload carries call-scoped events (ring, participant join/leave, call end). - `voice_state` — broadcast to a server. Payload includes `roomId`, `voiceGateway`, mute/deafen flags. - `server_users` — full peer roster on join; seeds the initial offer fan-out. - `user_joined` — schedules a fallback offer after a grace delay (`USER_JOINED_FALLBACK_OFFER_DELAY_MS`, ≈1 s). - `user_left` — peer teardown, with special handling that preserves peers still under an active voice session. - `connected` / `access_denied` — connection lifecycle (server bootstrap and authorization). The server is **purely signaling**: it does not track which `oderId` is in which voice room. Voice membership is derived client-side from the `voice_state` broadcasts observed on the server. --- ## Session establishment flow A new participant joining a voice room produces this exchange (initiator perspective; symmetrical when both arrive at once): 1. Local user clicks "Join voice" → `VoiceSessionFacade.startSession()` populates the session model and asks `voice-connection` to ready peer transport. 2. Server broadcasts `user_joined` to existing peers. 3. Each existing peer evaluates: am I the elected initiator for the (me, new-peer) pair? If yes, the peer-connection manager calls `doCreateAndSendOffer()`. 4. Initiator constructs `new RTCPeerConnection({ iceServers })` (`infrastructure/realtime/peer-connection-manager/.../create-peer-connection.ts`), adds local tracks, creates the data channel `chat`, generates an SDP offer, and sends it via the signaling transport. 5. Responder receives `offer` → `doHandleOffer()` sets remote description, generates SDP answer, sends `answer`. 6. Initiator receives `answer` → `doHandleAnswer()` sets remote description. 7. Both sides emit `ice_candidate` as they gather candidates via `onicecandidate`. 8. `iceConnectionState` reaches `connected` / `completed` → media flows. 9. Either side may open the `chat` data channel for P2P text payloads (direct messages, etc.). If the elected initiator never sends an offer within `NON_INITIATOR_GIVE_UP_MS`, the non-initiator promotes itself and initiates instead — preserves liveness across asymmetric drop-outs. `user_left` is treated carefully: the `signaling-message-handler.spec.ts` covers the case where a peer is still required by an active voice session and must not be torn down, even if other parts of the system think the peer has disconnected. --- ## Domain responsibilities ### `voice-session` (`toju-app/src/app/domains/voice-session/`) - `VoiceSessionFacade` (`application/facades/voice-session.facade.ts`) — owns the active session metadata (`serverId`, `roomId`, `participantIds`); drives a `showFloatingControls` signal when the user navigates away from the room. - `VoiceWorkspaceService` (`application/services/voice-workspace.service.ts`) — UI state for the workspace (hidden / expanded / minimized), focused stream ID, mini-window position. - `voiceSettingsStorage` (`infrastructure/util/voice-settings-storage.util.ts`) — localStorage persistence: input/output device IDs, output volume (0–100), bitrate (32–256 kbps), latency profile (`low | balanced | high`), noise-reduction toggle. - Joining a new voice target first calls `endSession()` so transitions cannot leak peer connections. ### `voice-connection` (`toju-app/src/app/domains/voice-connection/`) Bridges the application layer to the low-level WebRTC infrastructure under `toju-app/src/app/infrastructure/realtime/`. - **`VoiceActivityService`** — RMS-based speaking detection via `AnalyserNode` (fftSize 256, RMS ≥ 0.015, 8-frame grace period). - **`VoicePlaybackService`** — per-peer `GainNode` chains (0–200% range), localStorage-persisted; deafen sets all gains to 0. - **`VoiceConnectionFacade`** — exposes signals like `isVoiceConnected`, `isMuted`; methods like `toggleMute()`, `toggleNoiseReduction()`, `setOutputVolume()`. Per the domain README, voice-connection does **not** own RTCPeerConnection construction or signaling — those live in `infrastructure/realtime/peer-connection-manager`. ### `direct-call` (`toju-app/src/app/domains/direct-call/`) - Initiator flow (`DirectCallService.startCall()`): create/reuse the 1:1 DM, start a call-scoped voice session, send a `direct-call` "ring" envelope via `PeerDeliveryService`. - Recipient flow: store incoming session, ring `assets/audio/call.wav` (unless DND), show in-app modal + desktop notification. - Group upgrade: adding a third participant spawns a new group conversation; the active call swaps its chat panel to the new conversation but original DM history is preserved. - Invariant: incoming `direct-call` events are ignored unless the local user is in `participantIds`. ### Screen share (`toju-app/src/app/domains/screen-share/`) - Adds dedicated `MediaStreamTrack` senders to the existing peer connection (does not open a new one). - Request / response model: a receiver sends `screen-share-request`; the sender attaches the share track; `screen-share-stop` tears it down. - Quality presets: `low` / `balanced` / `high` (resolution + FPS). - On Electron, `ScreenShareSourcePickerService` drives a Promise-based picker over `getSources` (see [ipc-bridge](./ipc-bridge.md)). --- ## RNNoise pipeline Manager: `infrastructure/realtime/media/noise-reduction.manager.ts`. ``` Raw mic → MediaStreamAudioSourceNode → NoiseSuppressorWorklet (AudioWorkletNode) → MediaStreamAudioDestinationNode → clean stream → RTCPeerConnection sender ``` - AudioContext at 48 kHz. - Worklet loaded from `rnnoise-worklet.js` (built from `@timephy/rnnoise-wasm`, output written to `toju-app/public/`). - If worklet load fails, the raw stream is passed through unchanged. - Mute takes priority — when muted, noise reduction is also disabled. ## Technical implementation - **Envelope types**: see [websocket-envelopes](./websocket-envelopes.md). - **Signaling adapter (renderer)**: `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts` (and `signaling-transport-handler.ts`). - **Peer-connection manager**: `toju-app/src/app/infrastructure/realtime/peer-connection-manager/` — `create-peer-connection.ts`, recovery (grace timers, reconnect), data-channel plumbing. - **Voice settings**: `domains/voice-session/infrastructure/util/voice-settings-storage.util.ts`. - **Noise reduction**: `infrastructure/realtime/media/noise-reduction.manager.ts`. - **Worklet asset**: `toju-app/public/rnnoise-worklet.js`. - **Server side**: signaling only — `server/src/websocket/handler.ts::forwardRtcMessage`. ## Invariants - The server forwards `offer` / `answer` / `ice_candidate` / `direct-call` envelopes opaquely and never persists media or call state. - Switching voice rooms always tears down the prior session before starting the new one. - Mute overrides noise reduction (the manager disables the worklet path when muted). - Direct-call events with the local user absent from `participantIds` are ignored. ## Testing - `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — `user_left` peer preservation under active voice. - `toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts` — reconnect, grace timers, exponential backoff. - `toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.spec.ts`. - `toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts`. - E2E: `e2e/tests/voice/multi-signal-eight-user-voice.spec.ts`, `e2e/tests/voice/direct-call.spec.ts` (verify exact filenames in the suite — TODO). ## Security considerations - WebRTC bypasses the server entirely once connected — peer IPs may be exposed to other participants via ICE candidates. Standard WebRTC privacy caveat. - Signaling envelopes are forwarded without verifying that source and target share a server — TODO: confirm whether `forwardRtcMessage` enforces membership. - The data channel `chat` carries P2P text payloads; integrity / authentication of those payloads is owned by the chat/direct-message domains, not by this area. - RNNoise runs entirely client-side; mic audio never leaves the local AudioContext until it enters the encrypted RTCPeerConnection. ## Performance considerations - Mesh topology — N×(N-1)/2 peer connections per voice room. Practical ceiling is bound by client CPU and uplink; no documented soft cap. - Bitrate is client-controlled (32–256 kbps); no server-enforced QoS. - Voice activity detection runs at fftSize 256 with an 8-frame grace period — chosen to minimise CPU while staying responsive to natural speech. - The signaling server's only cost is envelope forwarding (O(1) per envelope). ## Known issues and limitations - **No SFU / MCU.** Large rooms scale linearly with participant count on each client. - **No recording or server-side mixing** for voice or screen. - **Bitrate is not enforced server-side** — adversarial clients could ignore the suggested range. - **No documented call-quality telemetry pipeline.** ## Related features - **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire types this area consumes. - **[ipc-bridge](./ipc-bridge.md)** — `getSources` and the Linux audio-routing methods are used by screen-share. - **[plugin-system](./plugin-system.md)** — plugins may participate as observers via `voice_state` broadcasts (subject to capability grants); no direct call control surface today. ## Changelog | Date | Change | |------|--------| | 2026-05-25 | Initial documentation |