Findings
Browser audio pipeline: one PulseAudio daemon per user chromium
Date: 2026-10-07
What we needed#
An agent running a full-duplex voice model has to hear and speak in a browser call (Zoom web, Slack huddle, Meet) driven through the built-in browser. Headless chromium has no audio devices.
What we found#
- Hooking audio inside the page (getUserMedia override, AudioContext taps, WebRTC track listeners) breaks per app: Zoom web mixes in WASM/worklets, huddles run in iframes, and a patched getUserMedia is detectable. OS-level virtual devices are the only app-agnostic layer.
- Chromium reads
PULSE_SINK/PULSE_SOURCEonce at spawn, so devices must exist before chromium starts; an already-running chromium needs a restart (BROWSER_RESTART_REQUIRED, thenrestart: true). - The daemon must be per user, not per process. With one shared daemon,
chromium's
enumerateDevices()lists every user'swb_sink_*.monitorand sinks, so a page in user A's browser couldgetUserMedia({deviceId})user B's call audio, orsetSinkIdinto B's microphone. Each user gets their own daemon in amkdtempruntime dir (unpredictable,0700) with an anonymous-auth unix socket; helper processes get a minimal environment (noSESSION_SECRET). The device key is 16 hex characters of an HMAC of the user id. Measured: chromium enumerates only its own daemon's devices. - A
module-pipe-sourcefixes its rate at load time and stalls rather than producing silence on underrun. The mic is instead a null sinkwb_mic_*with amodule-remap-sourceover its monitor, sopacat --rateresamples and the mic stays live between turns. - Pacing agent audio in the server (one 20 ms frame per tick into
pacat --latency-msec=20, monotonic clock, at most 5 ticks of catch-up) keeps the queue in-process, soclearis instant andplayed_msis accurate to about ±40 ms in steady state (catch-up after an event-loop stall can briefly exceed that), which is whatconversation.item.truncateneeds. Frames are written as whole samples only: after an underrun a chunk boundary that split a 16-bit sample would shift every later sample by a byte and turn the rest of the call into noise.played_mscounts agent audio only. - The mic permission is granted to at most one origin at a time. It follows
main-frame navigation (revoke the old origin, grant the new one) and is
revoked on end, with a
Browser.resetPermissionsfallback if a revoke fails. - The buffered affinity forward cannot carry an SSE stream or an uplink held
open for a call. A
node:httppipe can; fetch cannot (undici's 300 sheadersTimeoutfires before an uplink's response headers arrive). - The forward target must be the internal origin plus the request's path and
query only.
new URL(request.url, internal)lets an absolute-form request target (POST http://evil.example/...) override the origin, sending the caller's bearer or API key to another host. Found by automated security review; regression-tested with a raw request line. - Real end-to-end run (Docker on macOS arm64 under colima, PulseAudio as the image's user, no extra flags beyond the documented spawn env): downlink tone 440 Hz measured 440 to 442.5 Hz, uplink tone 880 Hz measured 877.5 Hz. Start-up was 0.4 to 2.75 s typical (one 9.9 s outlier). Dropouts after first audio were 0 to 0.2 of 40 ms blocks (one run at 0.33). A one-retry wrapper for the downlink measurement is pending: the change is not yet verified in a real run, so the numbers above are single attempts. Native Linux has not been measured; that is a release gate.
- Operational lessons from getting that run: macOS has no
timeoutbinary (an unboundeddocker exechung an agent for an hour; use a perlalarmshim). A CPU-starved colima VM (load around 155 from unrelated containers) madenpm ci, chromium spawn and image builds fail in confusing ways. A chromium leaked by a 20-day-old vitest run (workbench-vitest-*/browser-profiles, ppid 1) burned about two host cores and caused unrelated test timeouts.
What changed#
PulseAudio daemon per user (audio/pulse.ts), AudioSession
(audio/session.ts), per-user manager with mic permission that follows
main-frame navigation (audio/manager.ts), routes keyed by tab
(audio/routes.ts), streaming forward (audio/stream-forward.ts),
browser_audio_start/browser_audio_stop. Behind BROWSER_AUDIO_ENABLED.
Limits#
One call per user. Audio from the user's other tabs leaks into the stream. A pod rollout ends live calls. Recording/consent is the operator's responsibility.