4.6 KiB
Split user audio — investigation and plan
Status: investigated 2026-09-22. Not implemented. Until this lands, STT runs on the full mixed stream.
What happens today
The daemon does not see per-user audio. The client mixes every remote user before the audio leaves the browser.
| Step | Where | Detail |
|---|---|---|
| Playback | Vesktop renderer | Discord mixes the remote tracks. Per-user volume is applied before the mix. |
| Routing | poc/src/audio.ts |
pactl move-sink-input moves the client's playback streams into the VeskslopCapture sink. |
| Capture | poc/src/capture.ts |
ffmpeg records VeskslopCapture.monitor as raw PCM. One stream, all users. |
| Split | poc/src/vad.ts |
The VAD cuts the stream into utterances and writes one WAV each. |
| Text | poc/src/stt.ts, poc/src/transcript.ts |
STT turns each WAV into text when a provider is configured. |
| Speaker | poc/src/audio-service.ts |
VOICE_SPEAKING events give a best-effort speaker per segment. Overlaps and short segments can name the wrong user. |
The core bridge (src/renderer/puppet/actions.ts) has no WebRTC hooks today. The only audio call
is navigator.mediaDevices.enumerateDevices() for the device list.
Where per-user audio exists
Each remote user arrives in the renderer as a WebRTC MediaStreamTrack. Discord attaches the
tracks to an RTCPeerConnection and mixes them in an AudioContext graph. Per-user gain (the
volume sliders) is a node on each track's path. The OS output receives only the mix.
Split options
| Option | How | Cost |
|---|---|---|
Hook RTCPeerConnection |
Wrap ontrack and addEventListener("track") before Discord's code runs, then keep every remote track |
Straightforward to hook, but the track carries no user ID |
Hook AudioContext |
Wrap createMediaStreamSource and createGain. Discord makes one source per user, so the graph shows the per-user split |
Better mapping chance, but fragile against client updates |
| Map by event order | Pair the first chunk after a VOICE_SPEAKING event with that user |
Works with heavy traffic, mislabels overlaps |
| Chromium capture | Use Electron audio capture of webContents |
Gives the mix again, not per-user |
Recommended path: hook RTCPeerConnection for the tracks, and hook AudioContext only to map a
track to a user (the source node creation order matches the track order, and the per-user gain node
sits next to it). Keep the event-order pairing as the fallback for unknown tracks.
Transport for per-user PCM
The daemon needs one PCM stream per user. Two candidate shapes:
- AudioWorklet, 16 kHz mono s16le, base64 frames over the bridge. A 20 ms frame is 640 bytes, about 1.3 KB in base64. Five users cost about 320 KB per minute. Low latency, more bridge traffic, small renderer cost.
- MediaRecorder per track, Opus chunks every second. Lower traffic and less renderer CPU. The daemon decodes each chunk with ffmpeg before the VAD. Higher latency, more daemon CPU.
Recommendation: option 1. The bridge is a WebSocket with JSON frames already, and the daemon already runs ffmpeg for resampling. Add a binary frame type later when the volume grows.
Plan
| Step | Work |
|---|---|
| M1 | Renderer hook lists remote tracks (voice.tracks action: track ID, stream ID, label). No audio yet. This proves the hook and the mapping. |
| M2 | One track taps to an AudioWorklet, streams 16 kHz mono frames to the daemon, and the daemon writes a WAV. Loopback test only. |
| M3 | Map track to user ID (AudioContext hook, or the VOICE_SPEAKING fallback). Per-user VAD and transcript rows with a real speakerId. |
| M4 | The split user audio setting, per-user stop/start on join and leave, mute and deafen handling, and the mixed stream as the fallback. |
Setting
preferences.audio.splitUserAudio (boolean, default false), shown in Settings → Browser audio as
split user audio. When it is off, the mixed capture feeds STT as it does today. When it is on,
the daemon asks the core for the tracks, STT runs per user, and the mixed capture stays only for
the browser monitor.
Failure modes to handle
- Discord updates can break the hooks. Gate the feature on a capability probe (
voice.tracksanswers with a list) and fall back to the mixed stream. - Tracks appear and disappear during a call. Handle
track-addedandtrack-endedevents. - Muted or deafened users can still produce a track with silence. The VAD must drop it.
- The audio thread must never block. Copy in the worklet, send outside it.
- The feature needs a CPU budget per user. Cap the number of split tracks and report the rest as mixed.