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:
+120
-31
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user