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
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).
@@ -31,16 +31,17 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
**Image layering** (`Dockerfile`):
- 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.
- `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).
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**:
- `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).
- `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`.
@@ -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 /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`), 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.
- **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.
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`.
## 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
# 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.
# 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 && \
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/*
# 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 touch /root/.Xauthority
@@ -24,6 +31,7 @@ WORKDIR /
EXPOSE 60${DISPLAY_NUM}
EXPOSE 70${DISPLAY_NUM}
EXPOSE 71${DISPLAY_NUM}
EXPOSE 8090-8099
EXPOSE 5900
+26 -21
View File
@@ -69,27 +69,32 @@
with two different games running concurrently (separate processes, separate displays,
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
`alsa-utils` is installed, so DOSBox can write to an ALSA device inside the container, but
VNC/noVNC only ever streams video, not audio — nothing currently carries that sound out to
the browser.
- ~~Get game sound actually audible during play~~ Done: PulseAudio + a hand-rolled stdlib
WebSocket bridge (`pcm_ws_bridge.py`, in `docker-common` — reusable, not dosbox-specific,
per the earlier plan for this item) carries raw PCM to the browser, played back via a
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
a PulseAudio null sink piped through `ffmpeg` into a compressed (mp3/ogg) HTTP/Icecast-style
stream consumed by an `<audio>` tag: codec frame buffering plus the `<audio>` element's own
jitter buffer realistically puts that in the 1-3s range, no matter how it's tuned.
**Audio is a single shared mix, not per-game** — deliberately simplified from an
earlier per-slot-isolated design (considered and dropped as unnecessary complexity):
every running game's audio mixes into Pulse's one default sink, and every `/screen/<name>`
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
frames, e.g. `parec` with a short `--latency`) → push those frames to the browser over a
plain WebSocket (new endpoint alongside `setup_server.py`, or a small dedicated process) →
browser side, feed them straight into the Web Audio API via an `AudioWorkletNode` (not
`<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.
**Real bug found and fixed along the way**: `parec --device=@DEFAULT_SINK@.monitor`
(the seemingly-obvious macro syntax) fails with "Stream error: Invalid argument" —
PulseAudio has a *separate* single-token macro, `@DEFAULT_MONITOR@`, for exactly this;
concatenating `@DEFAULT_SINK@` with a literal `.monitor` suffix isn't valid.
Build this to be reusable across the other noVNC-family projects (`docker-xserver-novnc`,
`docker-sdr-novnc`), not dosbox-specific — it's a "browser audio + noVNC" concern, nothing
about it is really about DOSBox. Follow the `docker-common/scripts/signals.sh` precedent
(see the `docker-common` repo): keep the generic PulseAudio-sink/WebSocket/AudioWorklet
piece project-agnostic there, then copy it (not symlink) into each project's own `scripts/`
the same way signal handling is shared, so a fix in one place gets propagated by hand to
the others.
`server.sh` now starts `pulseaudio --start --exit-idle-time=-1` and the bridge
(`pcm_ws_bridge.py 7199 parec --device=@DEFAULT_MONITOR@ ...`) once, before `exec`-ing
`setup_server.py` — both live for the container's whole lifetime, independent of any
game's start/stop. `setup_server.py` gained `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 is now a plain
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
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
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
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.
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
@@ -45,10 +53,12 @@ SMB_SHARE = "software"
SMB_DIR = r"Games\dosbox"
GAMES_HOME = os.environ["GAMES_HOME"]
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_H = os.environ.get("SCREEN_H", "768")
NOVNC_CERT = "/tmp/novnc.pem"
NOVNC_KEY = "/tmp/novnc.key"
SCRIPTS_HOME = os.environ.get("SCRIPTS_HOME", "/opt/scripts")
MAX_CONCURRENT_GAMES = 10
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)
screen_html = ""
if name in novnc_ports:
port = novnc_ports[name]
link_id = f"novnc-{name}"
status_html = '<span class="status busy">Running</span>'
screen_html = (
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>"
)
screen_html = f'<a href="/screen/{name}" target="_blank">Open Screen</a>'
actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>'
@@ -331,6 +335,38 @@ button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
</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):
def log_message(self, fmt, *args):
pass
@@ -343,9 +379,34 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
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):
if urlparse(self.path).path == "/":
path = urlparse(self.path).path
if path == "/":
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:
self._send_html("Not found", status=404)