Files
Toju/toju-app/src/app/domains/voice-session/README.md
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

11 KiB

Voice Session Domain

Tracks voice session metadata across client-side navigation and manages the voice workspace UI state (expanded, minimized, hidden). This domain does not touch WebRTC directly; actual connections live in voice-connection and infrastructure/realtime.

The actual mixed live-stream workspace UI lives in features/room/voice-workspace and consumes VoiceWorkspaceService from this domain.

Module map

voice-session/
├── application/
│   ├── facades/
│   │   └── voice-session.facade.ts              Tracks active voice session, drives floating controls
│   └── 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
│
├── domain/
│   ├── 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
│   └── models/
│       └── voice-session.model.ts               VoiceSessionInfo interface
│
├── infrastructure/
│   └── util/
│       └── voice-settings-storage.util.ts       Persists audio device IDs, volumes, bitrate, latency, noise reduction to localStorage
│
├── feature/
│   ├── voice-controls/                      Full voice control panel (mic, camera, deafen, devices, screen share, settings)
│   └── floating-voice-controls/             Minimal overlay when user navigates away from the voice server
│
└── index.ts                                 Barrel exports

How the pieces connect

The facade manages session bookkeeping. The workspace service owns view state. Settings storage provides persistence for user preferences. Neither service opens any WebRTC connections.

graph TD
    VSF[VoiceSessionFacade]
    VWS[VoiceWorkspaceService]
    VSS[voiceSettingsStorage]
    Logic[voice-session.logic]
    VC[VoiceControlsComponent]
    FC[FloatingVoiceControlsComponent]
    Store[NgRx Store]

    VC --> VSF
    VC --> VWS
    VC --> VSS
    FC --> VSF
    FC --> VWS
    VSF --> Logic
    VSF --> Store
    VWS --> VSF

    click VSF "application/facades/voice-session.facade.ts" "Tracks active voice session" _blank
    click VWS "application/services/voice-workspace.service.ts" "Workspace mode and focused stream" _blank
    click VSS "infrastructure/util/voice-settings-storage.util.ts" "localStorage persistence for audio settings" _blank
    click Logic "domain/logic/voice-session.logic.ts" "Pure helper functions" _blank
    click VC "feature/voice-controls/" "Full voice control panel" _blank
    click FC "feature/floating-voice-controls/" "Minimal floating overlay" _blank

Session lifecycle

stateDiagram-v2
    [*] --> NoSession
    NoSession --> Active: startSession(info)
    Active --> Active: checkCurrentRoute(serverId)
    Active --> NoSession: endSession()
    
    state Active {
        [*] --> ViewingServer
        ViewingServer --> AwayFromServer: navigated to different server
        AwayFromServer --> ViewingServer: navigated back / navigateToVoiceServer()
    }

When a voice session is active and the user navigates away from the voice-connected server, showFloatingControls becomes true and the floating overlay appears. Clicking the overlay dispatches RoomsActions.viewServer to navigate back.

Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.

Multi-device voice (Discord-style)

Each install has a stable clientInstanceId (ClientInstanceService). VoiceState.clientInstanceId records which device currently owns the microphone/WebRTC session for that user.

  • Local voice owner — this device's clientInstanceId matches voiceState.clientInstanceId; mic, heartbeat, and WebRTC transmit run normally.
  • Passive client — another device owns voice; this client still receives chat/presence and shows grayed "in voice on another device" UI in the room sidebar and private-call cards.
  • Takeover — clicking Join on a passive client sends voice_client_takeover through signaling; the active device releases voice via VoiceClientTakeoverService, then the passive client completes a normal join.

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.

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.

Workspace modes

VoiceWorkspaceService controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server.

stateDiagram-v2
    [*] --> Hidden
    Hidden --> Expanded: open()
    Expanded --> Minimized: minimize()
    Expanded --> Hidden: close() / showChat()
    Minimized --> Expanded: restore()
    Minimized --> Hidden: close()
    Expanded --> 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>.

Voice settings

Settings are stored in localStorage under a single JSON key. All values are validated and clamped on load to defend against corrupt storage.

Setting Default Range
inputDevice "" device ID string
outputDevice "" device ID string
inputVolume 100 0 -- 100
outputVolume 100 0 -- 100
audioBitrate 96 kbps 32 -- 256
latencyProfile "balanced" low / balanced / high
noiseReduction true boolean
screenShareQuality "balanced" low / balanced / high
askScreenShareQuality true 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.

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.