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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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);
|
||||
Executable
+112
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user