- Removed the "Enable Sound" button: AudioContext/worklet/WebSocket now
connect eagerly on page load; only ctx.resume() still needs a user
gesture, piggybacking on the page's first click/keypress (e.g.
clicking Start) instead of a dedicated audio-only button.
- Added a per-game volume slider (POST /volume). Despite audio being
one shared PulseAudio mix, this is a real independent control: every
game is still its own distinct sink-input, found by matching
`pactl -f json list sink-inputs`'s application.process.id against
the game's own PID, then `pactl set-sink-input-volume`.
- Real bug: audio still never played after removing the button, because
Install/Start/Stop/Uninstall were still <form method="post"> submits.
Every click caused a full page navigation (303 redirect), tearing
down whatever AudioContext had just connected; the fresh page after
reload creates a new suspended context with no further gesture to
unlock it. Tell: no speaker icon ever appeared on the Chrome tab.
Fixed by removing <form>s entirely - every button is now
onclick="doAction(...)", do_POST returns a plain 204, and client-side
doAction()/refresh() fetch() the action and the updated page, then
swap only #content's innerHTML. The page itself never navigates, so
the audio connection survives every action. setInterval(refresh,
3000) replaces the old <meta refresh> for keeping status current
without that risk.
- Real bug: once audio worked, ~2s of latency that got worse over time
plus multi-second delay before volume changes were audible - fixed
upstream in docker-common's pcm-worklet.js (uncapped playback queue),
propagated here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
VNC/noVNC only ever streams video, so game sound needed a completely
separate path. Adds pulseaudio/pulseaudio-utils/libasound2-plugins and
routes ALSA's default device through Pulse (/etc/asound.conf), so
dosbox/scummvm need zero special config. server.sh starts one
PulseAudio daemon and one pcm_ws_bridge.py (from docker-common) for the
container's whole lifetime, capturing Pulse's single default sink via
parec.
Audio is a single shared mix, not per-game - considered and dropped a
per-slot-isolated design (mirroring the video architecture) as
unnecessary complexity per direction. Every running game's audio just
mixes into the one default sink; every /screen/<name> page connects to
the same AUDIO_PORT.
setup_server.py gains GET /screen/<name> (an iframe onto the game's
noVNC screen plus an Enable Sound button - browsers require a user
gesture before audio can start) and GET /pcm-worklet.js. The "Open
Screen" link simplifies from a client-side-JS-built cross-port link to
a plain same-origin relative link, since /screen/<name> now reads the
real host server-side from the request's own Host header.
Found and fixed a real bug along the way: parec --device=@DEFAULT_SINK@.monitor
looks correct but fails with "Stream error: Invalid argument" - the
actual PulseAudio macro is the single token @DEFAULT_MONITOR@.
Verified end-to-end with real audio, not just plumbing: confirmed via
`pactl list sink-inputs` that dosbox connects to Pulse correctly
(unmuted, uncorked), then used xdotool to advance stuntcar past its
silent title screen and captured real audible game audio (RMS ~9292)
through the WebSocket bridge with a raw Python client.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz