Audio on by default, per-game volume, and fix real navigation + latency bugs

- 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
This commit is contained in:
2026-07-28 19:15:51 +02:00
co-authored by Claude Sonnet 5
parent eb63f46978
commit 588086e82c
4 changed files with 203 additions and 40 deletions
+50 -3
View File
@@ -119,8 +119,55 @@
`basicConstraints=critical,CA:FALSE`, `keyUsage=critical,digitalSignature,keyEncipherment`,
and `extendedKeyUsage=serverAuth` in the same `openssl req -x509 -addext ...` call.
Because the setup page now hosts a persistent `AudioContext`/`WebSocket`, its
`<meta http-equiv="refresh">` auto-refresh was narrowed to only fire while an install is
Because the setup page now hosts a persistent `AudioContext`/`WebSocket`, its old
`<meta http-equiv="refresh">` auto-refresh was narrowed to only fire while an install was
actively in progress — it used to also refresh continuously whenever any game was running
(to catch a crashed game's slot being freed), which would have torn down the live audio
connection every 3 seconds.
connection every 3 seconds. **Superseded entirely below** by a JS-driven refresh that
doesn't navigate the page at all.
**Removed the "Enable Sound" button — audio is on by default now.** The `AudioContext`/
`AudioWorkletNode`/`WebSocket` all connect eagerly on page load; the only thing still
gated on a user gesture (unavoidable browser autoplay policy) is `ctx.resume()`, which
now piggybacks on the page's very first click or keypress — whatever the user was already
doing, e.g. clicking Start on a game — rather than requiring a dedicated audio-only click.
**Added a per-game volume slider.** Despite audio being one shared mix, this is a real,
independent control: every running game is still its own distinct PulseAudio sink-input
even though they all feed the same sink, and `pactl set-sink-input-volume` adjusts one
sink-input without touching the others. `_sink_input_index()` finds the right one by
matching `pactl -f json list sink-inputs`' `application.process.id` against the game's own
PID (confirmed exact via `ps`/`pactl` cross-check). The slider POSTs to a new `/volume`
route via `fetch()` on every tick (not a form submit — no page navigation per drag step).
**Real bug found right after removing the button**: audio still never actually played.
Install/Start/Stop/Uninstall were still plain `<form method="post">` submits — every click
caused a full page navigation (via the server's `303` redirect back to `/`), which tears
down whatever `AudioContext` was just connected. The fresh page after that reload creates
a brand-new *suspended* context with no further gesture to unlock it (the reload itself
doesn't count), so in completely normal usage — load the page, click Start — audio never
actually starts. Symptom that nailed it down: no speaker icon ever appeared on the Chrome
tab. Fixed by converting the whole page away from form-based navigation entirely: every
button is now a bare `<button onclick="doAction(...)">`, `do_POST` returns a plain `204`
instead of a `303` redirect, and client-side `doAction()`/`refresh()` `fetch()` the action
and the updated page, then swap just `#content`'s `innerHTML` in place — the page itself
never navigates, so the audio connection now survives every install/start/stop/uninstall
click, and `setInterval(refresh, 3000)` replaces the old `<meta refresh>` for keeping
status current (crashed-game slot cleanup, install progress) without that risk at all.
**Third real bug, found once audio was actually reaching the speakers**: ~2s of latency,
visibly getting *worse* the longer a game ran (lip sync drifting further out over time),
and volume-slider changes taking a couple seconds to actually be heard. Root cause:
`pcm-worklet.js`'s playback queue (`docker-common/scripts/pcm-worklet.js`, copied into
this project) had no size cap — every incoming WebSocket message just got `push()`ed on
regardless of how fast the network delivered it relative to real-time playback. On a fast
local connection the browser routinely receives data faster than 48kHz real-time consumes
it, so the backlog only ever grew, never shrank; `set-sink-input-volume` changes the volume
at the PulseAudio *source*, so anything already sitting in that ever-growing client queue
still played at the old volume until it drained, i.e. the whole visible backlog's worth of
delay before a slider change was audible. Fixed by capping the queue at ~100ms
(`maxQueuedFrames`) and dropping the *oldest* excess data whenever a new chunk would push
it over that cap — verified directly in Node (stubbing `AudioWorkletProcessor`/
`sampleRate`/`registerProcessor`) that force-feeding a simulated 4.3s burst leaves only
~85ms actually queued afterward, instead of growing unbounded. Propagated to both the
`docker-common` canonical copy and this project's own copy, per the usual convention.