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