- 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
73 lines
2.5 KiB
JavaScript
73 lines
2.5 KiB
JavaScript
// Generic AudioWorkletProcessor for low-latency raw PCM playback over a
|
|
// WebSocket (paired with pcm_ws_bridge.py on the server side). Expects
|
|
// 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);
|
|
for (let i = 0; i < int16.length; i++) {
|
|
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;
|
|
const numFrames = output[0].length;
|
|
|
|
for (let frame = 0; frame < numFrames; frame++) {
|
|
if (this.queue.length === 0) {
|
|
for (let ch = 0; ch < numChannels; ch++) output[ch][frame] = 0;
|
|
continue;
|
|
}
|
|
const current = this.queue[0];
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
const idx = this.readOffset * this.channels + ch;
|
|
output[ch][frame] = idx < current.length ? current[idx] : 0;
|
|
}
|
|
this.readOffset++;
|
|
if (this.readOffset * this.channels >= current.length) {
|
|
this.queue.shift();
|
|
this.readOffset = 0;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor('pcm-worklet-processor', PCMWorkletProcessor);
|