Get game audio into the browser via PulseAudio + a WebSocket bridge

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
This commit is contained in:
2026-07-28 16:27:10 +02:00
co-authored by Claude Sonnet 5
parent c1942f5ec4
commit 301503a276
8 changed files with 284 additions and 36 deletions
+13 -4
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is ## What this is
A Docker image definition for headless DOS/SCUMM games exposed over noVNC (VNC-over-websockets in the browser). It builds on the public base image `ich777/novnc-baseimage`, which already provides TurboVNC, websockify, fluxbox, and noVNC — this repo adds `dosbox`/`scummvm`/`alsa-utils` (game runtimes) plus `unzip`/`smbclient`/`python3` for game management, a TLS cert for noVNC, and the entrypoint/lifecycle scripts. Published as `jayfield/dosbox-novnc` on Docker Hub. `scummvm`'s binary is at `/usr/games/scummvm`, not `/usr/bin` (Debian's convention for game packages) and not reliably on `$PATH` under a non-login shell — manifests reference it by absolute path. A Docker image definition for headless DOS/SCUMM games exposed over noVNC (VNC-over-websockets in the browser), with game audio also carried to the browser over a second, separate WebSocket (VNC/noVNC itself only ever streams video). It builds on the public base image `ich777/novnc-baseimage`, which already provides TurboVNC, websockify, fluxbox, and noVNC — this repo adds `dosbox`/`scummvm`/`alsa-utils` (game runtimes), `pulseaudio`/`pulseaudio-utils`/`libasound2-plugins` (audio), plus `unzip`/`smbclient`/`python3` for game management, a TLS cert for noVNC, and the entrypoint/lifecycle scripts. Published as `jayfield/dosbox-novnc` on Docker Hub. `scummvm`'s binary is at `/usr/games/scummvm`, not `/usr/bin` (Debian's convention for game packages) and not reliably on `$PATH` under a non-login shell — manifests reference it by absolute path.
Game data (game installs, sourced as zips from an internal SMB share `//vlda-01/software/`) is deliberately **not** baked into the image — one of the games (`t7g`, "The 7th Guest") is ~930MB of CD-ROM ISOs on its own, more than the rest of the image combined. Instead the image ships empty and games are installed at runtime into a volume, via an HTML setup page (see below). Game data (game installs, sourced as zips from an internal SMB share `//vlda-01/software/`) is deliberately **not** baked into the image — one of the games (`t7g`, "The 7th Guest") is ~930MB of CD-ROM ISOs on its own, more than the rest of the image combined. Instead the image ships empty and games are installed at runtime into a volume, via an HTML setup page (see below).
@@ -31,16 +31,17 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
**Image layering** (`Dockerfile`): **Image layering** (`Dockerfile`):
- Base image supplies TurboVNC (`Xvnc` on `$PATH`), websockify (`websockify` on `$PATH`), and noVNC's web client at `/usr/share/novnc/`. - Base image supplies TurboVNC (`Xvnc` on `$PATH`), websockify (`websockify` on `$PATH`), and noVNC's web client at `/usr/share/novnc/`.
- This layer adds `dosbox`/`alsa-utils` (the actual game runtime) and `unzip`/`smbclient`/`python3` (needed permanently at *runtime* now, not just at build time — see below), generates a self-signed TLS cert/key at build time (`/tmp/novnc.pem`, `/tmp/novnc.key`) for noVNC's HTTPS/WSS listener, and copies `scripts/` to `/opt/scripts/`. - This layer adds `dosbox`/`scummvm`/`alsa-utils` (game runtimes), `pulseaudio`/`pulseaudio-utils`/`libasound2-plugins` (audio, see below), and `unzip`/`smbclient`/`python3` (needed permanently at *runtime* now, not just at build time — see below), writes `/etc/asound.conf`, generates a self-signed TLS cert/key at build time (`/tmp/novnc.pem`, `/tmp/novnc.key`) for noVNC's HTTPS/WSS listener, and copies `scripts/` to `/opt/scripts/`.
- `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time. - `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time.
- `ENTRYPOINT` is `/opt/scripts/server_start.sh /opt/scripts/server.sh` — the signal-handling wrapper invoked with the real startup script as its argument. - `ENTRYPOINT` is `/opt/scripts/server_start.sh /opt/scripts/server.sh` — the signal-handling wrapper invoked with the real startup script as its argument.
**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance). **Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance).
There is **no single shared desktop/display** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below). There is **no single shared desktop/display for video** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts PulseAudio and the audio bridge (see below) once, then `exec`s nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below). Audio, in contrast, *is* shared for the container's whole lifetime — see the Audio section below.
**Port scheme**: **Port scheme**:
- `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`. - `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`.
- `71${DISPLAY_NUM}` — the shared **audio WebSocket** (`AUDIO_PORT`, `7199` by default) — see Audio below. `run.sh` publishes this as `7199:7199`.
- `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family). - `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family).
- `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started. - `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started.
- `60${DISPLAY_NUM}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`. - `60${DISPLAY_NUM}``EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
@@ -56,7 +57,7 @@ There is **no single shared desktop/display** — unlike the other noVNC-family
- **`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 /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. - `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. - `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`), pointing at that game's specific noVNC port. Its `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time — has to be client-side since the container may be reached via different hostnames/IPs and each game's port is only known once it's actually started. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root. - Each running game's row shows its own "Open Screen" link (only while `Running`) — a plain same-origin relative link to `GET /screen/<name>` (see Audio below for what that route actually renders).
- 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 while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything. - 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 while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything.
- **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. - **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.
@@ -64,6 +65,14 @@ There is **no single shared desktop/display** — unlike the other noVNC-family
1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent. 1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent.
2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention, and — since there's no more a shared default display at all — has no `$DISPLAY` to render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — see `TODO.md`. 2. `game.sh <name>``docker exec -it dosbox-novnc start_game.sh <name>`, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-<version>.conf` convention, and — since there's no more a shared default display at all — has no `$DISPLAY` to render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — see `TODO.md`.
**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.
- **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.
- `GET /screen/<name>` (in `setup_server.py`) is what the "Open Screen" link on the main page actually points at: a small server-rendered page with an `<iframe>` onto that game's noVNC URL, plus an "Enable Sound" button (browsers require a user gesture before audio can start) that creates an `AudioContext`, loads `/pcm-worklet.js` via `audioWorklet.addModule`, and opens a `WebSocket` to `AUDIO_PORT`, piping incoming binary messages into the worklet node. The host used to build the iframe/WebSocket URLs is read server-side from the incoming request's `Host:` header — no client-side hostname-guessing JS needed for this page (unlike the old global noVNC link it replaced).
- 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.
**Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`. **Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`.
## Sizing history ## Sizing history
+9 -1
View File
@@ -9,10 +9,17 @@ ENV PATH=${PATH}:${SCRIPTS_HOME}
# unzip/smbclient/python3 are needed at runtime: the games are no longer baked into the # unzip/smbclient/python3 are needed at runtime: the games are no longer baked into the
# image, setup_server.py serves an HTML install UI (backed by smbget+unzip) that the user # image, setup_server.py serves an HTML install UI (backed by smbget+unzip) that the user
# drives on ${SETUP_PORT} instead of anything being auto-downloaded at container start. # drives on ${SETUP_PORT} instead of anything being auto-downloaded at container start.
# pulseaudio/pulseaudio-utils/libasound2-plugins carry game audio out to the browser (see
# scripts/pcm_ws_bridge.py) - VNC/noVNC only ever streams video, never audio.
RUN apt-get update && \ RUN apt-get update && \
apt-get -y install --no-install-recommends dosbox scummvm alsa-utils unzip smbclient python3 && \ apt-get -y install --no-install-recommends dosbox scummvm alsa-utils unzip smbclient python3 \
pulseaudio pulseaudio-utils libasound2-plugins && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Route ALSA's default device through PulseAudio, so dosbox/scummvm need no special config -
# every game's audio ends up mixed into Pulse's one default sink, captured by pcm_ws_bridge.py.
RUN printf 'pcm.!default pulse\nctl.!default pulse\n' > /etc/asound.conf
RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 -subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=NY emailAddress=email@example.com" RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 -subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=NY emailAddress=email@example.com"
RUN touch /root/.Xauthority RUN touch /root/.Xauthority
@@ -24,6 +31,7 @@ WORKDIR /
EXPOSE 60${DISPLAY_NUM} EXPOSE 60${DISPLAY_NUM}
EXPOSE 70${DISPLAY_NUM} EXPOSE 70${DISPLAY_NUM}
EXPOSE 71${DISPLAY_NUM}
EXPOSE 8090-8099 EXPOSE 8090-8099
EXPOSE 5900 EXPOSE 5900
+26 -21
View File
@@ -69,27 +69,32 @@
with two different games running concurrently (separate processes, separate displays, with two different games running concurrently (separate processes, separate displays,
separate noVNC ports, independent stop/teardown) and the 10-slot cap logic. separate noVNC ports, independent stop/teardown) and the 10-slot cap logic.
- Get game sound actually audible during play. `--device /dev/snd` is passed through and - ~~Get game sound actually audible during play~~ Done: PulseAudio + a hand-rolled stdlib
`alsa-utils` is installed, so DOSBox can write to an ALSA device inside the container, but WebSocket bridge (`pcm_ws_bridge.py`, in `docker-common` — reusable, not dosbox-specific,
VNC/noVNC only ever streams video, not audio — nothing currently carries that sound out to per the earlier plan for this item) carries raw PCM to the browser, played back via a
the browser. Web Audio `AudioWorkletNode` (`pcm-worklet.js`, also in `docker-common`). Verified real
end-to-end audio: DOSBox → ALSA (default device, routed through Pulse via
`/etc/asound.conf`) → PulseAudio's one default sink → `parec` → the bridge → a raw
WebSocket client, measured RMS ≈9292 while `stuntcar` was actually playing (vs. silence
before advancing past its title screen) — not just a plumbing check, actual game audio.
Target latency is a few tens of ms, not seconds — that rules out the "obvious" approach of **Audio is a single shared mix, not per-game** — deliberately simplified from an
a PulseAudio null sink piped through `ffmpeg` into a compressed (mp3/ogg) HTTP/Icecast-style earlier per-slot-isolated design (considered and dropped as unnecessary complexity):
stream consumed by an `<audio>` tag: codec frame buffering plus the `<audio>` element's own every running game's audio mixes into Pulse's one default sink, and every `/screen/<name>`
jitter buffer realistically puts that in the 1-3s range, no matter how it's tuned. page connects to the same single `AUDIO_PORT` (`71${DISPLAY_NUM}`, `7199` by default).
Trade-off: with more than one game running, you can't tell their audio apart.
Concrete approach instead: PulseAudio null sink → capture raw/lightly-buffered PCM (small **Real bug found and fixed along the way**: `parec --device=@DEFAULT_SINK@.monitor`
frames, e.g. `parec` with a short `--latency`) → push those frames to the browser over a (the seemingly-obvious macro syntax) fails with "Stream error: Invalid argument" —
plain WebSocket (new endpoint alongside `setup_server.py`, or a small dedicated process) → PulseAudio has a *separate* single-token macro, `@DEFAULT_MONITOR@`, for exactly this;
browser side, feed them straight into the Web Audio API via an `AudioWorkletNode` (not concatenating `@DEFAULT_SINK@` with a literal `.monitor` suffix isn't valid.
`<audio>`, not `ScriptProcessorNode` — that's deprecated and has worse latency) for
near-real-time scheduled playback. No container/codec framing in the path at all.
Build this to be reusable across the other noVNC-family projects (`docker-xserver-novnc`, `server.sh` now starts `pulseaudio --start --exit-idle-time=-1` and the bridge
`docker-sdr-novnc`), not dosbox-specific — it's a "browser audio + noVNC" concern, nothing (`pcm_ws_bridge.py 7199 parec --device=@DEFAULT_MONITOR@ ...`) once, before `exec`-ing
about it is really about DOSBox. Follow the `docker-common/scripts/signals.sh` precedent `setup_server.py` — both live for the container's whole lifetime, independent of any
(see the `docker-common` repo): keep the generic PulseAudio-sink/WebSocket/AudioWorklet game's start/stop. `setup_server.py` gained `GET /screen/<name>` (an iframe onto the
piece project-agnostic there, then copy it (not symlink) into each project's own `scripts/` game's noVNC screen plus an "Enable Sound" button — browsers require a user gesture
the same way signal handling is shared, so a fix in one place gets propagated by hand to before audio can start) and `GET /pcm-worklet.js`; the "Open Screen" link is now a plain
the others. same-origin relative link (`/screen/<name>`) instead of the old client-side-JS-built
cross-port link, since the wrapper page itself now handles connecting to the right ports
(reading the actual host from the request's own `Host:` header server-side).
+1 -1
View File
@@ -1,4 +1,4 @@
#/bin/bash #/bin/bash
docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 8090-8099:8090-8099 jayfield/dosbox-novnc docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 7199:7199 -p 8090-8099:8090-8099 jayfield/dosbox-novnc
+47
View File
@@ -0,0 +1,47 @@
// Generic AudioWorkletProcessor for low-latency raw PCM playback over a
// WebSocket (paired with pcm_ws_bridge.py on the server side). Expects
// 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.
class PCMWorkletProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readOffset = 0;
this.port.onmessage = (event) => {
const int16 = new Int16Array(event.data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768;
}
this.queue.push(float32);
};
}
process(inputs, outputs) {
const output = outputs[0];
const numChannels = output.length;
const numFrames = output[0].length;
for (let frame = 0; frame < numFrames; frame++) {
if (this.queue.length === 0) {
for (let ch = 0; ch < numChannels; ch++) output[ch][frame] = 0;
continue;
}
const current = this.queue[0];
for (let ch = 0; ch < numChannels; ch++) {
const idx = this.readOffset * numChannels + ch;
output[ch][frame] = idx < current.length ? current[idx] : 0;
}
this.readOffset++;
if (this.readOffset * numChannels >= current.length) {
this.queue.shift();
this.readOffset = 0;
}
}
return true;
}
}
registerProcessor('pcm-worklet-processor', PCMWorkletProcessor);
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Generic stdlib-only WebSocket bridge: runs a command, streams its stdout to
every connected WebSocket client as binary frames. One-directional (server ->
browser) by design, so it never parses masked client frames beyond detecting a
closed connection - built for low-latency PCM audio streaming, but the command
run is arbitrary.
Usage: pcm_ws_bridge.py <port> <command> [args...]
"""
import base64
import hashlib
import socket
import subprocess
import sys
import threading
WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
clients = []
clients_lock = threading.Lock()
def handshake(conn):
request = b""
while b"\r\n\r\n" not in request:
chunk = conn.recv(4096)
if not chunk:
return False
request += chunk
key = None
for line in request.split(b"\r\n")[1:]:
if line.lower().startswith(b"sec-websocket-key:"):
key = line.split(b":", 1)[1].strip()
break
if not key:
return False
accept = base64.b64encode(hashlib.sha1(key + WS_MAGIC.encode()).digest()).decode()
conn.sendall(
(
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
).encode()
)
return True
def frame(data):
length = len(data)
if length <= 125:
header = bytes([0x82, length])
elif length <= 0xFFFF:
header = bytes([0x82, 126]) + length.to_bytes(2, "big")
else:
header = bytes([0x82, 127]) + length.to_bytes(8, "big")
return header + data
def handle_client(conn):
if not handshake(conn):
conn.close()
return
with clients_lock:
clients.append(conn)
try:
while conn.recv(4096):
pass # not expecting client messages, just watching for disconnect
except OSError:
pass
finally:
with clients_lock:
if conn in clients:
clients.remove(conn)
conn.close()
def broadcast_loop(proc):
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
packet = frame(chunk)
with clients_lock:
dead = []
for c in clients:
try:
c.sendall(packet)
except OSError:
dead.append(c)
for c in dead:
clients.remove(c)
def main():
port = int(sys.argv[1])
cmd = sys.argv[2:]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
threading.Thread(target=broadcast_loop, args=(proc,), daemon=True).start()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", port))
server.listen(5)
while True:
conn, _ = server.accept()
threading.Thread(target=handle_client, args=(conn,), daemon=True).start()
if __name__ == "__main__":
main()
+7 -1
View File
@@ -2,5 +2,11 @@
set -x set -x
SETUP_PORT=70${DISPLAY_NUM} SETUP_PORT=70${DISPLAY_NUM}
AUDIO_PORT=71${DISPLAY_NUM}
SETUP_PORT=${SETUP_PORT} exec ${SCRIPTS_HOME}/setup_server.py pulseaudio --start --exit-idle-time=-1 --log-target=stderr >/var/log/pulseaudio.log 2>&1
${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} \
parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20 &
SETUP_PORT=${SETUP_PORT} AUDIO_PORT=${AUDIO_PORT} exec ${SCRIPTS_HOME}/setup_server.py
+69 -8
View File
@@ -28,6 +28,14 @@ Each running game gets its own ephemeral X session (Xvnc + fluxbox +
websockify on their own display/ports) instead of sharing one screen, so up websockify on their own display/ports) instead of sharing one screen, so up
to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or
being invisible to each other. Starting past that cap is refused. being invisible to each other. Starting past that cap is refused.
Audio is deliberately *not* per-game: server.sh starts one PulseAudio daemon
and one pcm_ws_bridge.py (from docker-common) for the whole container's
lifetime, capturing PulseAudio's single default sink. Every running game's
audio mixes together there (normal PulseAudio behavior, no per-game routing
needed), and every /screen/<name> page connects to that same shared
AUDIO_PORT - simpler than per-slot audio isolation, at the cost of not being
able to tell games' audio apart when more than one is running.
""" """
import html import html
@@ -45,10 +53,12 @@ SMB_SHARE = "software"
SMB_DIR = r"Games\dosbox" SMB_DIR = r"Games\dosbox"
GAMES_HOME = os.environ["GAMES_HOME"] GAMES_HOME = os.environ["GAMES_HOME"]
SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080")) SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080"))
AUDIO_PORT = int(os.environ.get("AUDIO_PORT", "8081"))
SCREEN_W = os.environ.get("SCREEN_W", "1024") SCREEN_W = os.environ.get("SCREEN_W", "1024")
SCREEN_H = os.environ.get("SCREEN_H", "768") SCREEN_H = os.environ.get("SCREEN_H", "768")
NOVNC_CERT = "/tmp/novnc.pem" NOVNC_CERT = "/tmp/novnc.pem"
NOVNC_KEY = "/tmp/novnc.key" NOVNC_KEY = "/tmp/novnc.key"
SCRIPTS_HOME = os.environ.get("SCRIPTS_HOME", "/opt/scripts")
MAX_CONCURRENT_GAMES = 10 MAX_CONCURRENT_GAMES = 10
GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot
@@ -274,14 +284,8 @@ def render_page():
status = pending.get(name) status = pending.get(name)
screen_html = "" screen_html = ""
if name in novnc_ports: if name in novnc_ports:
port = novnc_ports[name]
link_id = f"novnc-{name}"
status_html = '<span class="status busy">Running</span>' status_html = '<span class="status busy">Running</span>'
screen_html = ( screen_html = f'<a href="/screen/{name}" target="_blank">Open Screen</a>'
f'<a id="{link_id}" href="#" target="_blank">Open Screen</a>'
f"<script>document.getElementById('{link_id}').href = "
f"'https://' + window.location.hostname + ':{port}/';</script>"
)
actions = button("stop", name, "Stop") actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"): elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>' status_html = f'<span class="status busy">{status}...</span>'
@@ -331,6 +335,38 @@ button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
</html>""" </html>"""
def render_screen_page(name, novnc_port, host):
return f"""<!doctype html>
<html>
<head>
<title>{html.escape(name)}</title>
<style>
html, body {{ margin: 0; padding: 0; height: 100%; overflow: hidden; }}
iframe {{ border: none; width: 100%; height: 100%; }}
#audio-btn {{ position: fixed; top: 1em; right: 1em; z-index: 10; padding: 0.5em 1em; }}
</style>
</head>
<body>
<button id="audio-btn">Enable Sound</button>
<iframe src="https://{host}:{novnc_port}/vnc.html?autoconnect=true"></iframe>
<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');
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';
}});
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
pass pass
@@ -343,9 +379,34 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(encoded) self.wfile.write(encoded)
def _send_file(self, path, content_type):
try:
with open(path, "rb") as f:
content = f.read()
except OSError:
self._send_html("Not found", status=404)
return
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_GET(self): def do_GET(self):
if urlparse(self.path).path == "/": path = urlparse(self.path).path
if path == "/":
self._send_html(render_page()) self._send_html(render_page())
elif path == "/pcm-worklet.js":
self._send_file(os.path.join(SCRIPTS_HOME, "pcm-worklet.js"), "application/javascript")
elif path.startswith("/screen/"):
name = path[len("/screen/"):]
with state_lock:
info = running_procs.get(name)
if info is None:
self._send_html(f"<p>{html.escape(name)} is not running.</p>", status=404)
return
host = self.headers.get("Host", "localhost").rsplit(":", 1)[0]
self._send_html(render_screen_page(name, info["novnc_port"], host))
else: else:
self._send_html("Not found", status=404) self._send_html("Not found", status=404)