Audio on by default, per-game volume, and fix real navigation + latency bugs

- Removed the "Enable Sound" button: AudioContext/worklet/WebSocket now
  connect eagerly on page load; only ctx.resume() still needs a user
  gesture, piggybacking on the page's first click/keypress (e.g.
  clicking Start) instead of a dedicated audio-only button.

- Added a per-game volume slider (POST /volume). Despite audio being
  one shared PulseAudio mix, this is a real independent control: every
  game is still its own distinct sink-input, found by matching
  `pactl -f json list sink-inputs`'s application.process.id against
  the game's own PID, then `pactl set-sink-input-volume`.

- Real bug: audio still never played after removing the button, because
  Install/Start/Stop/Uninstall were still <form method="post"> submits.
  Every click caused a full page navigation (303 redirect), tearing
  down whatever AudioContext had just connected; the fresh page after
  reload creates a new suspended context with no further gesture to
  unlock it. Tell: no speaker icon ever appeared on the Chrome tab.
  Fixed by removing <form>s entirely - every button is now
  onclick="doAction(...)", do_POST returns a plain 204, and client-side
  doAction()/refresh() fetch() the action and the updated page, then
  swap only #content's innerHTML. The page itself never navigates, so
  the audio connection survives every action. setInterval(refresh,
  3000) replaces the old <meta refresh> for keeping status current
  without that risk.

- Real bug: once audio worked, ~2s of latency that got worse over time
  plus multi-second delay before volume changes were audible - fixed
  upstream in docker-common's pcm-worklet.js (uncapped playback queue),
  propagated here.

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 19:15:51 +02:00
co-authored by Claude Sonnet 5
parent eb63f46978
commit 588086e82c
4 changed files with 203 additions and 40 deletions
+27 -2
View File
@@ -3,12 +3,23 @@
// 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.
//
// The queue is deliberately capped (see maxQueuedFrames): if the network/
// browser ever delivers data faster than real-time playback consumes it -
// which happens routinely on a fast local connection - an uncapped queue
// just accumulates that backlog forever, growing steadily out of sync with
// the video instead of settling back down. Dropping the oldest excess data
// keeps latency bounded at the cost of an occasional small audible glitch
// on the drop, which is a fair trade for staying live.
class PCMWorkletProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readOffset = 0;
this.channels = 2;
this.maxQueuedFrames = Math.round(sampleRate * 0.1); // ~100ms cap
this.port.onmessage = (event) => {
const int16 = new Int16Array(event.data);
const float32 = new Float32Array(int16.length);
@@ -16,9 +27,23 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
float32[i] = int16[i] / 32768;
}
this.queue.push(float32);
this._trimBacklog();
};
}
_queuedFrames() {
let frames = -this.readOffset;
for (const chunk of this.queue) frames += chunk.length / this.channels;
return frames;
}
_trimBacklog() {
while (this._queuedFrames() > this.maxQueuedFrames && this.queue.length > 1) {
this.queue.shift();
this.readOffset = 0;
}
}
process(inputs, outputs) {
const output = outputs[0];
const numChannels = output.length;
@@ -31,11 +56,11 @@ class PCMWorkletProcessor extends AudioWorkletProcessor {
}
const current = this.queue[0];
for (let ch = 0; ch < numChannels; ch++) {
const idx = this.readOffset * numChannels + ch;
const idx = this.readOffset * this.channels + ch;
output[ch][frame] = idx < current.length ? current[idx] : 0;
}
this.readOffset++;
if (this.readOffset * numChannels >= current.length) {
if (this.readOffset * this.channels >= current.length) {
this.queue.shift();
this.readOffset = 0;
}
+120 -31
View File
@@ -42,12 +42,23 @@ page with an <iframe>: an iframe onto the noVNC HTTPS port hits browsers
certificate whose warning hasn't been accepted at the top level, with no
way to click through *inside* the iframe. So "Open Screen" is a plain
top-level link straight to noVNC's own URL (opens in a new tab, normal
"Accept the Risk and Continue" applies), and the one "Enable Sound" toggle
lives on this page instead, meant to be left open in its own tab for as
long as you're playing. Because of that, this page's auto-refresh is
deliberately limited to only while an install is actively in progress -
refreshing while a game is running would tear down the live AudioContext/
WebSocket every few seconds.
"Accept the Risk and Continue" applies), and audio lives on this page
instead, meant to be left open in its own tab for as long as you're
playing. Because of that, this page's auto-refresh is deliberately
limited to only while an install is actively in progress - refreshing
while a game is running would tear down the live AudioContext/WebSocket
every few seconds.
Audio is on by default, no button: the AudioContext/WebSocket connect
eagerly on page load, and just get resumed on this page's first click or
keypress (whatever the user was already doing, e.g. clicking Start),
since browsers require a user gesture before an AudioContext will
actually produce sound. Per-game volume is a real, independent control
even though every game's audio mixes into one shared PulseAudio sink -
each running game is still its own distinct sink-input there, and
PulseAudio already supports adjusting one sink-input's volume without
affecting the others (`pactl set-sink-input-volume`), found by matching
`application.process.id` against the game's own PID.
"""
import html
@@ -265,6 +276,40 @@ def stop_game(name):
running_procs.pop(name, None)
def _sink_input_index(pid):
"""Finds the PulseAudio sink-input belonging to a given PID.
All games mix into PulseAudio's one default sink (see module docstring),
but each game is still its own distinct sink-input there, and PulseAudio
already supports adjusting one sink-input's volume independently of the
others - this is what backs the per-game volume slider, without needing
any per-game audio isolation.
"""
result = subprocess.run(
["pactl", "-f", "json", "list", "sink-inputs"],
capture_output=True, text=True, timeout=5,
)
try:
sink_inputs = json.loads(result.stdout)
except json.JSONDecodeError:
return None
for sink_input in sink_inputs:
if sink_input.get("properties", {}).get("application.process.id") == str(pid):
return sink_input.get("index")
return None
def set_volume(name, level):
with state_lock:
info = running_procs.get(name)
if info is None:
return
index = _sink_input_index(info["game"].pid)
if index is None:
return
subprocess.run(["pactl", "set-sink-input-volume", str(index), f"{level}%"], check=False)
def render_page(host):
games = discover_games()
sizes = get_zip_sizes(games)
@@ -276,16 +321,12 @@ def render_page(host):
novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()}
slots_used = len(novnc_ports)
any_pending = any(v in ("downloading", "extracting") for v in pending.values())
# Deliberately not refreshing just because slots_used > 0: this page hosts the one
# persistent "Enable Sound" AudioContext/WebSocket, and a full-page refresh would kill it.
refresh_tag = '<meta http-equiv="refresh" content="3">' if any_pending else ""
def button(action, name, label):
# Not a <form> - a real form submit navigates the page, which would tear down the
# persistent audio AudioContext/WebSocket below. doAction() fetch()es instead and
# re-renders just #content in place, so the page never actually navigates.
return (
f'<form method="post" action="/{action}">'
f'<input type="hidden" name="name" value="{html.escape(name)}">'
f'<button type="submit">{label}</button></form>'
f'<button onclick="doAction(\'{action}\',\'{name}\')">{label}</button>'
)
rows = []
@@ -297,9 +338,16 @@ def render_page(host):
size_html = f"{size / 1_000_000:.1f} MB" if size else "?"
status = pending.get(name)
screen_html = ""
volume_html = ""
if name in novnc_ports:
status_html = '<span class="status busy">Running</span>'
screen_html = f'<a href="https://{host}:{novnc_ports[name]}/" target="_blank">Open Screen</a>'
volume_html = (
f'<input type="range" min="0" max="150" value="100" class="volume" '
f"oninput=\"fetch('/volume',{{method:'POST',"
f"headers:{{'Content-Type':'application/x-www-form-urlencoded'}},"
f"body:'name={name}&level='+this.value}})\">"
)
actions = button("stop", name, "Stop")
elif status in ("downloading", "extracting"):
status_html = f'<span class="status busy">{status}...</span>'
@@ -318,48 +366,75 @@ def render_page(host):
actions = button("install", name, "Install")
rows.append(
f"<tr><td>{title}</td><td>{release_date}</td><td>{publisher}</td>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td><td>{screen_html}</td></tr>"
f"<td>{size_html}</td><td>{status_html}</td><td>{actions}</td>"
f"<td>{screen_html}</td><td>{volume_html}</td></tr>"
)
return f"""<!doctype html>
<html>
<head>
<title>dosbox-novnc game setup</title>
{refresh_tag}
<style>
body {{ font-family: sans-serif; margin: 2em; }}
table {{ border-collapse: collapse; width: 100%; max-width: 800px; }}
th, td {{ text-align: left; padding: 0.5em 1em; border-bottom: 1px solid #ccc; }}
form {{ margin: 0; display: inline; }}
button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }}
.status.ok {{ color: #2a2; font-weight: bold; }}
.status.busy {{ color: #a70; font-weight: bold; }}
.status.err {{ color: #c22; }}
.slots {{ margin-bottom: 1.5em; }}
#audio-btn {{ padding: 0.4em 1em; margin-bottom: 1.5em; }}
.volume {{ width: 80px; vertical-align: middle; }}
</style>
</head>
<body>
<button id="audio-btn">Enable Sound</button>
<div id="content">
<p class="slots">{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use</p>
<h1>DOSBox games</h1>
<table>
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th></tr>
{''.join(rows) if rows else '<tr><td colspan="7">No game manifests found on the share</td></tr>'}
<tr><th>Game</th><th>Release</th><th>Publisher</th><th>Size</th><th>Status</th><th>Actions</th><th></th><th>Volume</th></tr>
{''.join(rows) if rows else '<tr><td colspan="8">No game manifests found on the share</td></tr>'}
</table>
</div>
<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');
// None of the buttons/inputs above are <form>s - a real form submit (or any other full
// page navigation) would tear down the persistent audio AudioContext/WebSocket set up
// below. doAction()/refresh() fetch() instead and only ever replace #content in place, so
// the page itself never navigates, no matter how many games get installed/started/stopped.
async function refresh() {{
const res = await fetch('/');
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
document.getElementById('content').innerHTML = doc.getElementById('content').innerHTML;
}}
async function doAction(action, name) {{
await fetch('/' + action, {{
method: 'POST',
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
body: 'name=' + encodeURIComponent(name),
}});
refresh();
}}
setInterval(refresh, 3000);
// Audio is on by default - no button. AudioContexts start suspended until a user
// gesture, so this connects everything eagerly and just resumes on the page's first
// click/keypress (whatever the user was already doing, e.g. clicking Start on a game).
// Safe from the refresh() calls above too, since those never touch anything outside
// #content - this script block, and the AudioContext/WebSocket it created, are untouched.
const ctx = new AudioContext({{ sampleRate: 48000 }});
ctx.audioWorklet.addModule('/pcm-worklet.js').then(() => {{
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 - keep this tab open while playing';
}});
const unlockAudio = () => {{
ctx.resume();
document.removeEventListener('click', unlockAudio);
document.removeEventListener('keydown', unlockAudio);
}};
document.addEventListener('click', unlockAudio);
document.addEventListener('keydown', unlockAudio);
</script>
</body>
</html>"""
@@ -402,13 +477,26 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
action = urlparse(self.path).path.lstrip("/")
if action not in ("install", "uninstall", "start", "stop"):
if action not in ("install", "uninstall", "start", "stop", "volume"):
self._send_html("Not found", status=404)
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
name = parse_qs(body).get("name", [""])[0]
params = parse_qs(body)
name = params.get("name", [""])[0]
if action == "volume":
# Fired on every slider tick via fetch(), not a form submit - no page navigation.
try:
level = int(params.get("level", ["100"])[0])
except ValueError:
level = 100
set_volume(name, level)
self.send_response(204)
self.end_headers()
return
games = discover_games()
if name in games:
@@ -422,8 +510,9 @@ class Handler(BaseHTTPRequestHandler):
elif action == "stop":
threading.Thread(target=stop_game, args=(name,), daemon=True).start()
self.send_response(303)
self.send_header("Location", "/")
# No redirect: the client's own doAction()/refresh() re-fetches "/" itself and
# replaces #content in place, never navigating the page (see render_page()'s JS).
self.send_response(204)
self.end_headers()