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
113 lines
3.0 KiB
Python
Executable File
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()
|