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
+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)