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
48 lines
1.5 KiB
JavaScript
48 lines
1.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.
|
|
|
|
class PCMWorkletProcessor extends AudioWorkletProcessor {
|
|
constructor() {
|
|
super();
|
|
this.queue = [];
|
|
this.readOffset = 0;
|
|
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);
|
|
};
|
|
}
|
|
|
|
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 * numChannels + ch;
|
|
output[ch][frame] = idx < current.length ? current[idx] : 0;
|
|
}
|
|
this.readOffset++;
|
|
if (this.readOffset * numChannels >= current.length) {
|
|
this.queue.shift();
|
|
this.readOffset = 0;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor('pcm-worklet-processor', PCMWorkletProcessor);
|