Files
docker-dosbox-novnc/scripts/pcm_ws_bridge.py
T
jensandClaude Sonnet 5 301503a276 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
2026-07-28 16:27:10 +02:00

113 lines
3.0 KiB
Python
Executable File

#!/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()