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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user