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:
@@ -51,14 +51,14 @@ There is **no single shared desktop/display for video** — unlike the other noV
|
||||
**Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects:
|
||||
- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started.
|
||||
- Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `<name>.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `<name>.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls "<path>"` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced.
|
||||
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top.
|
||||
- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top. **None of the action buttons are `<form>`s** — every one is a bare `<button onclick="doAction('<action>','<name>')">`; `doAction()` (client-side JS in the page itself) `fetch()`s the action and then calls `refresh()`, which re-`fetch()`es `/` and replaces `#content`'s `innerHTML` in place. The page itself never navigates. This matters a lot — see Audio below for why a real `<form>` submit here was an actual bug, not just a style choice.
|
||||
- `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-<name>` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/<name>` directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g. `monkey2.zip` → `monkey2/` — convenient, not enforced by the code).
|
||||
- `POST /uninstall`: `rm -rf ${GAMES_HOME}/<name>`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete.
|
||||
- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}` → `80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`.
|
||||
- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display.
|
||||
- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling.
|
||||
- Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain top-level link straight to that game's own noVNC URL (`https://<host>:<novnc_port>/`, opens in a new tab). Deliberately **not** an iframe — see Audio below for why.
|
||||
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s **only** while an install is actively in flight — deliberately *not* also while a game is running (unlike earlier in this project's history), since this page now also hosts the persistent audio toggle (see below) and a full-page refresh would kill that connection. One consequence: a game that crashes/exits on its own won't show its slot as freed until the next manual reload (`is_running()`'s lazy cleanup still runs on that next load, just not automatically every 3s anymore).
|
||||
- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page keeps itself current via `setInterval(refresh, 3000)` (client-side JS, not a server-driven `<meta http-equiv="refresh">` — that would navigate the page and kill the persistent audio connection, see Audio below), so a crashed/exited game's slot shows as freed within a few seconds without ever reloading the page.
|
||||
- **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added.
|
||||
|
||||
**Playing a game — two overlapping paths right now**:
|
||||
@@ -68,9 +68,11 @@ There is **no single shared desktop/display for video** — unlike the other noV
|
||||
**Audio** — VNC/noVNC only ever streams video, so game sound needs a completely separate path to the browser:
|
||||
- `/etc/asound.conf` (`pcm.!default pulse` / `ctl.!default pulse`, set at build time) routes ALSA's default device through PulseAudio, so `dosbox`/`scummvm` need zero special configuration — confirmed via `pactl list sink-inputs` that a running game shows up as a normal, unmuted, uncorked PulseAudio client with no extra flags.
|
||||
- `server.sh` starts `pulseaudio --start --exit-idle-time=-1` once, then `${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20` (backgrounded) before `exec`-ing `setup_server.py`. **`@DEFAULT_MONITOR@` is the correct macro** — `@DEFAULT_SINK@.monitor` (concatenating the sink macro with a literal `.monitor` suffix) looks like it should work but fails with `Stream error: Invalid argument`; this cost real debugging time, don't reintroduce it.
|
||||
- `pcm_ws_bridge.py` and `pcm-worklet.js` are copies from `docker-common/scripts/` (same copy-not-symlink convention as `signals.sh`) — genuinely reusable, nothing dosbox-specific in either file. `pcm_ws_bridge.py` is a small hand-rolled stdlib-only WebSocket server (handshake via `socket`/`hashlib`/`base64`, manual binary frame writing) since no `websocat`-equivalent package exists in this Debian release and adding a pip dependency would break `setup_server.py`'s stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design. `pcm-worklet.js` is the matching `AudioWorkletProcessor`: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter.
|
||||
- `pcm_ws_bridge.py` and `pcm-worklet.js` are copies from `docker-common/scripts/` (same copy-not-symlink convention as `signals.sh`) — genuinely reusable, nothing dosbox-specific in either file. `pcm_ws_bridge.py` is a small hand-rolled stdlib-only WebSocket server (handshake via `socket`/`hashlib`/`base64`, manual binary frame writing) since no `websocat`-equivalent package exists in this Debian release and adding a pip dependency would break `setup_server.py`'s stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design. `pcm-worklet.js` is the matching `AudioWorkletProcessor`: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter — **capped at ~100ms, dropping the oldest excess on overflow, which is load-bearing** (see `docker-common/CLAUDE.md` for why: an uncapped queue was the real cause of ~2s of latency that got worse over time and made volume changes take seconds to be heard).
|
||||
- **Audio is a single shared mix, not per-game** — every running game's audio ends up mixed into PulseAudio's one default sink (normal PulseAudio behavior, multiple clients to one sink just mix), and there is exactly one bridge process/port (`AUDIO_PORT`) for the container's entire lifetime, independent of any individual game's start/stop. This was a deliberate simplification from an earlier per-slot-isolated design (one sink/bridge/port per game slot, mirroring the video architecture) that was designed and then dropped as unnecessary complexity — the trade-off is that with more than one game running you can't tell their audio apart.
|
||||
- **The "Enable Sound" toggle lives on the main setup page (`render_page()`), not a per-game page** — creates an `AudioContext`, loads `/pcm-worklet.js` via `audioWorklet.addModule`, opens a `WebSocket` to `AUDIO_PORT`, and pipes incoming binary messages into the worklet node. Meant to be left running in its own tab for as long as you're playing (hence the auto-refresh restriction above). `host` is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed.
|
||||
- **Audio is on by default on the main setup page (`render_page()`), not a per-game page, and needs no button.** The `AudioContext`, `/pcm-worklet.js` (`audioWorklet.addModule`), and the `WebSocket` to `AUDIO_PORT` all connect eagerly on page load, piping incoming binary messages into the worklet node. The only thing gated on a user gesture (browsers won't produce sound otherwise) is `ctx.resume()`, called by a one-shot listener on the page's very first `click`/`keydown` — whatever the user was already doing (e.g. clicking Start) — not a dedicated audio-only button. Meant to be left running in its own tab for as long as you're playing. `host` is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed.
|
||||
- **The page must never navigate, or the audio connection dies — this was a real, found bug, not just style.** Install/Start/Stop/Uninstall used to be `<form method="post">` submits; every click caused a full page reload via the server's `303` redirect, tearing down whatever `AudioContext` had just connected. The fresh page after that reload creates a *new*, suspended context with no further gesture to unlock it (the reload itself doesn't count as one), so in completely ordinary use — load the page, click Start — audio never actually played. The tell was that Chrome's tab never showed its audio-playing speaker icon. Fixed by removing `<form>`s entirely: every button is `onclick="doAction(action, name)"`, `do_POST` returns a bare `204` instead of a `303`/`Location` redirect, and `doAction()`/`refresh()` (both inline JS in `render_page()`) `fetch()` the action plus the updated page and swap only `#content`'s `innerHTML` — the page itself never navigates, so the `AudioContext`/`WebSocket` set up outside `#content` survive every action.
|
||||
- **Per-game volume, despite the shared mix**: `POST /volume` (`name`, `level` 0-150) calls `set_volume()`, which finds that game's PulseAudio *sink-input* — matching `pactl -f json list sink-inputs`' `properties["application.process.id"]` against `running_procs[name]["game"].pid` — and runs `pactl set-sink-input-volume <index> <level>%`. Works because every game is still its own distinct sink-input even though they all feed the same default sink; PulseAudio already supports adjusting one independently of the others, no per-game audio isolation required. Each running game's row has a `<input type="range">` that `fetch()`s this route on every `oninput` tick (not a form submit, so no page navigation per drag step).
|
||||
- **Video and audio are deliberately on separate pages, not one `<iframe>`-based wrapper.** An earlier version had `GET /screen/<name>` render an iframe onto noVNC plus the audio button on one page — dropped after finding that Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through *inside* the iframe (Chrome is more lenient, which is why this briefly looked fine when only tested there). Now "Open Screen" just opens noVNC's own URL directly in a new tab, where the normal top-level "Accept the Risk and Continue" flow applies.
|
||||
- **The self-signed cert's `CN`/SAN/extensions all matter for Firefox specifically.** Generated with `CN=localhost` + `subjectAltName=DNS:localhost,IP:127.0.0.1` (not the meaningless placeholder `CN=NY` this project originally inherited) — Firefox validates the WebSocket-Secure connection's certificate against the hostname *independently* of the page load, and rejected a CN mismatch there even after the top-level HTTPS warning had been accepted. Only covers access via `localhost`/`127.0.0.1`; a LAN IP or other hostname will hit the same mismatch, since a static cert can't be pre-populated with every possible address. **Also needs explicit `basicConstraints=critical,CA:FALSE` plus `keyUsage`/`extendedKeyUsage=serverAuth`** — without them, `openssl req -x509 -addext ...` defaults to `CA:TRUE`, and Firefox hard-refuses (no override option at all, unlike a normal self-signed warning) a CA-flagged cert used as a TLS server cert. If the cert's `openssl req` invocation in the Dockerfile is ever touched again, keep all of these `-addext` flags together.
|
||||
- Realistic end-to-end latency lands somewhere in the tens-to-~150ms range (`parec`'s own buffering plus the worklet's small jitter-absorbing ring buffer) — not literally "tens of ms" best-case, but nowhere near the 1-3s a compressed-stream (`ffmpeg` → mp3/ogg → `<audio>` tag) approach would cost, which is why that approach was rejected up front.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+27
-2
@@ -3,12 +3,23 @@
|
||||
// interleaved 16-bit signed little-endian stereo frames delivered via
|
||||
// postMessage as ArrayBuffers; converts to Float32 and plays them back
|
||||
// through a small ring buffer that absorbs network jitter.
|
||||
//
|
||||
// The queue is deliberately capped (see maxQueuedFrames): if the network/
|
||||
// browser ever delivers data faster than real-time playback consumes it -
|
||||
// which happens routinely on a fast local connection - an uncapped queue
|
||||
// just accumulates that backlog forever, growing steadily out of sync with
|
||||
// the video instead of settling back down. Dropping the oldest excess data
|
||||
// keeps latency bounded at the cost of an occasional small audible glitch
|
||||
// on the drop, which is a fair trade for staying live.
|
||||
|
||||
class PCMWorkletProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = [];
|
||||
this.readOffset = 0;
|
||||
this.channels = 2;
|
||||
this.maxQueuedFrames = Math.round(sampleRate * 0.1); // ~100ms cap
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const int16 = new Int16Array(event.data);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
@@ -16,9 +27,23 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
|
||||
float32[i] = int16[i] / 32768;
|
||||
}
|
||||
this.queue.push(float32);
|
||||
this._trimBacklog();
|
||||
};
|
||||
}
|
||||
|
||||
_queuedFrames() {
|
||||
let frames = -this.readOffset;
|
||||
for (const chunk of this.queue) frames += chunk.length / this.channels;
|
||||
return frames;
|
||||
}
|
||||
|
||||
_trimBacklog() {
|
||||
while (this._queuedFrames() > this.maxQueuedFrames && this.queue.length > 1) {
|
||||
this.queue.shift();
|
||||
this.readOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
const numChannels = output.length;
|
||||
@@ -31,11 +56,11 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
const current = this.queue[0];
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const idx = this.readOffset * numChannels + ch;
|
||||
const idx = this.readOffset * this.channels + ch;
|
||||
output[ch][frame] = idx < current.length ? current[idx] : 0;
|
||||
}
|
||||
this.readOffset++;
|
||||
if (this.readOffset * numChannels >= current.length) {
|
||||
if (this.readOffset * this.channels >= current.length) {
|
||||
this.queue.shift();
|
||||
this.readOffset = 0;
|
||||
}
|
||||
|
||||
+120
-31
@@ -42,12 +42,23 @@ page with an <iframe>: an iframe onto the noVNC HTTPS port hits browsers
|
||||
certificate whose warning hasn't been accepted at the top level, with no
|
||||
way to click through *inside* the iframe. So "Open Screen" is a plain
|
||||
top-level link straight to noVNC's own URL (opens in a new tab, normal
|
||||
"Accept the Risk and Continue" applies), and the one "Enable Sound" toggle
|
||||
lives on this page instead, meant to be left open in its own tab for as
|
||||
long as you're playing. Because of that, this page's auto-refresh is
|
||||
deliberately limited to only while an install is actively in progress -
|
||||
refreshing while a game is running would tear down the live AudioContext/
|
||||
WebSocket every few seconds.
|
||||
"Accept the Risk and Continue" applies), and audio lives on this page
|
||||
instead, meant to be left open in its own tab for as long as you're
|
||||
playing. Because of that, this page's auto-refresh is deliberately
|
||||
limited to only while an install is actively in progress - refreshing
|
||||
while a game is running would tear down the live AudioContext/WebSocket
|
||||
every few seconds.
|
||||
|
||||
Audio is on by default, no button: the AudioContext/WebSocket connect
|
||||
eagerly on page load, and just get resumed on this page's first click or
|
||||
keypress (whatever the user was already doing, e.g. clicking Start),
|
||||
since browsers require a user gesture before an AudioContext will
|
||||
actually produce sound. Per-game volume is a real, independent control
|
||||
even though every game's audio mixes into one shared PulseAudio sink -
|
||||
each running game is still its own distinct sink-input there, and
|
||||
PulseAudio already supports adjusting one sink-input's volume without
|
||||
affecting the others (`pactl set-sink-input-volume`), found by matching
|
||||
`application.process.id` against the game's own PID.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -265,6 +276,40 @@ def stop_game(name):
|
||||
running_procs.pop(name, None)
|
||||
|
||||
|
||||
def _sink_input_index(pid):
|
||||
"""Finds the PulseAudio sink-input belonging to a given PID.
|
||||
|
||||
All games mix into PulseAudio's one default sink (see module docstring),
|
||||
but each game is still its own distinct sink-input there, and PulseAudio
|
||||
already supports adjusting one sink-input's volume independently of the
|
||||
others - this is what backs the per-game volume slider, without needing
|
||||
any per-game audio isolation.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["pactl", "-f", "json", "list", "sink-inputs"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
try:
|
||||
sink_inputs = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
for sink_input in sink_inputs:
|
||||
if sink_input.get("properties", {}).get("application.process.id") == str(pid):
|
||||
return sink_input.get("index")
|
||||
return None
|
||||
|
||||
|
||||
def set_volume(name, level):
|
||||
with state_lock:
|
||||
info = running_procs.get(name)
|
||||
if info is None:
|
||||
return
|
||||
index = _sink_input_index(info["game"].pid)
|
||||
if index is None:
|
||||
return
|
||||
subprocess.run(["pactl", "set-sink-input-volume", str(index), f"{level}%"], check=False)
|
||||
|
||||
|
||||
def render_page(host):
|
||||
games = discover_games()
|
||||
sizes = get_zip_sizes(games)
|
||||
@@ -276,16 +321,12 @@ def render_page(host):
|
||||
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
|
||||
slots_used = len(novnc_ports)
|
||||
|
||||
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
|
||||
# Deliberately not refreshing just because slots_used > 0: this page hosts the one
|
||||
# persistent "Enable Sound" AudioContext/WebSocket, and a full-page refresh would kill it.
|
||||
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
|
||||
|
||||
def button(action, name, label):
|
||||
# Not a <form> - a real form submit navigates the page, which would tear down the
|
||||
# persistent audio AudioContext/WebSocket below. doAction() fetch()es instead and
|
||||
# re-renders just #content in place, so the page never actually navigates.
|
||||
return (
|
||||
f'<form method="post" action="/{action}">'
|
||||
f'<input type="hidden" name="name" value="{html.escape(name)}">'
|
||||
f'<button type="submit">{label}</button></form>'
|
||||
f'<button onclick="doAction(\'{action}\',\'{name}\')">{label}</button>'
|
||||
)
|
||||
|
||||
rows = []
|
||||
@@ -297,9 +338,16 @@ def render_page(host):
|
||||
size_html = f"{size / 1_000_000:.1f} MB" if size else "?"
|
||||
status = pending.get(name)
|
||||
screen_html = ""
|
||||
volume_html = ""
|
||||
if name in novnc_ports:
|
||||
status_html = '<span class="status busy">Running</span>'
|
||||
screen_html = f'<a href="https://{host}:{novnc_ports[name]}/" target="_blank">Open Screen</a>'
|
||||
volume_html = (
|
||||
f'<input type="range" min="0" max="150" value="100" class="volume" '
|
||||
f"oninput=\"fetch('/volume',{{method:'POST',"
|
||||
f"headers:{{'Content-Type':'application/x-www-form-urlencoded'}},"
|
||||
f"body:'name={name}&level='+this.value}})\">"
|
||||
)
|
||||
actions = button("stop", name, "Stop")
|
||||
elif status in ("downloading", "extracting"):
|
||||
status_html = f'<span class="status busy">{status}...</span>'
|
||||
@@ -318,48 +366,75 @@ def render_page(host):
|
||||
actions = button("install", name, "Install")
|
||||
rows.append(
|
||||
f"<tr><td>{title}</td><td>{release_date}</td><td>{publisher}</td>"
|
||||
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td><td>{screen_html}</td></tr>"
|
||||
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td>"
|
||||
f"<td>{screen_html}</td><td>{volume_html}</td></tr>"
|
||||
)
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>dosbox-novnc game setup</title>
|
||||
{refresh_tag}
|
||||
<style>
|
||||
body {{ font-family: sans-serif; margin: 2em; }}
|
||||
table {{ border-collapse: collapse; width: 100%; max-width: 800px; }}
|
||||
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
|
||||
form {{ margin: 0; display: inline; }}
|
||||
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
|
||||
.status.ok {{ color: #2a2; font-weight: bold; }}
|
||||
.status.busy {{ color: #a70; font-weight: bold; }}
|
||||
.status.err {{ color: #c22; }}
|
||||
.slots {{ margin-bottom: 1.5em; }}
|
||||
#audio-btn {{ padding: 0.4em 1em; margin-bottom: 1.5em; }}
|
||||
.volume {{ width: 80px; vertical-align: middle; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="audio-btn">Enable Sound</button>
|
||||
<div id="content">
|
||||
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
|
||||
<h1>DOSBox games</h1>
|
||||
<table>
|
||||
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th></tr>
|
||||
{''.join(rows) if rows else '<tr><td colspan="7">No game manifests found on the share</td></tr>'}
|
||||
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th><th>Volume</th></tr>
|
||||
{''.join(rows) if rows else '<tr><td colspan="8">No game manifests found on the share</td></tr>'}
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('audio-btn').addEventListener('click', async () => {{
|
||||
const btn = document.getElementById('audio-btn');
|
||||
const ctx = new AudioContext({{ sampleRate: 48000 }});
|
||||
await ctx.audioWorklet.addModule('/pcm-worklet.js');
|
||||
// None of the buttons/inputs above are <form>s - a real form submit (or any other full
|
||||
// page navigation) would tear down the persistent audio AudioContext/WebSocket set up
|
||||
// below. doAction()/refresh() fetch() instead and only ever replace #content in place, so
|
||||
// the page itself never navigates, no matter how many games get installed/started/stopped.
|
||||
async function refresh() {{
|
||||
const res = await fetch('/');
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
||||
document.getElementById('content').innerHTML = doc.getElementById('content').innerHTML;
|
||||
}}
|
||||
async function doAction(action, name) {{
|
||||
await fetch('/' + action, {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
|
||||
body: 'name=' + encodeURIComponent(name),
|
||||
}});
|
||||
refresh();
|
||||
}}
|
||||
setInterval(refresh, 3000);
|
||||
|
||||
// Audio is on by default - no button. AudioContexts start suspended until a user
|
||||
// gesture, so this connects everything eagerly and just resumes on the page's first
|
||||
// click/keypress (whatever the user was already doing, e.g. clicking Start on a game).
|
||||
// Safe from the refresh() calls above too, since those never touch anything outside
|
||||
// #content - this script block, and the AudioContext/WebSocket it created, are untouched.
|
||||
const ctx = new AudioContext({{ sampleRate: 48000 }});
|
||||
ctx.audioWorklet.addModule('/pcm-worklet.js').then(() => {{
|
||||
const node = new AudioWorkletNode(ctx, 'pcm-worklet-processor', {{ outputChannelCount: [2] }});
|
||||
node.connect(ctx.destination);
|
||||
const ws = new WebSocket('ws://{host}:{AUDIO_PORT}/');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onmessage = (event) => node.port.postMessage(event.data, [event.data]);
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sound enabled - keep this tab open while playing';
|
||||
}});
|
||||
const unlockAudio = () => {{
|
||||
ctx.resume();
|
||||
document.removeEventListener('click', unlockAudio);
|
||||
document.removeEventListener('keydown', unlockAudio);
|
||||
}};
|
||||
document.addEventListener('click', unlockAudio);
|
||||
document.addEventListener('keydown', unlockAudio);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -402,13 +477,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
action = urlparse(self.path).path.lstrip("/")
|
||||
if action not in ("install", "uninstall", "start", "stop"):
|
||||
if action not in ("install", "uninstall", "start", "stop", "volume"):
|
||||
self._send_html("Not found", status=404)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length).decode("utf-8")
|
||||
name = parse_qs(body).get("name", [""])[0]
|
||||
params = parse_qs(body)
|
||||
name = params.get("name", [""])[0]
|
||||
|
||||
if action == "volume":
|
||||
# Fired on every slider tick via fetch(), not a form submit - no page navigation.
|
||||
try:
|
||||
level = int(params.get("level", ["100"])[0])
|
||||
except ValueError:
|
||||
level = 100
|
||||
set_volume(name, level)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
games = discover_games()
|
||||
|
||||
if name in games:
|
||||
@@ -422,8 +510,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif action == "stop":
|
||||
threading.Thread(target=stop_game, args=(name,), daemon=True).start()
|
||||
|
||||
self.send_response(303)
|
||||
self.send_header("Location", "/")
|
||||
# No redirect: the client's own doAction()/refresh() re-fetches "/" itself and
|
||||
# replaces #content in place, never navigating the page (see render_page()'s JS).
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user