89 lines
2.9 KiB
JavaScript
89 lines
2.9 KiB
JavaScript
/**
|
|
* playback-processor.js — AudioWorkletProcessor for per-speaker audio playback.
|
|
*
|
|
* Runs on the dedicated audio worklet thread (off the main JS thread).
|
|
*
|
|
* Responsibilities:
|
|
* - Maintain a Float32 ring buffer (~1 second at 48 kHz).
|
|
* - Accept { pcm: Float32Array } messages (transferable ArrayBuffer) written
|
|
* by the main thread after AudioDecoder.decode() produces output.
|
|
* - Drain the ring buffer into the output channel on each process() call;
|
|
* output silence when the buffer is empty (natural jitter absorption).
|
|
*
|
|
* One instance is created per remote speaker alongside its AudioDecoder.
|
|
*/
|
|
|
|
const RING_CAPACITY = 48000; // 1 second @ 48 kHz
|
|
|
|
class PlaybackProcessor extends AudioWorkletProcessor {
|
|
constructor() {
|
|
super();
|
|
|
|
this._ring = new Float32Array(RING_CAPACITY);
|
|
this._writePos = 0; // next write index (mod RING_CAPACITY)
|
|
this._readPos = 0; // next read index (mod RING_CAPACITY)
|
|
this._available = 0; // samples currently buffered
|
|
|
|
this.port.onmessage = (e) => {
|
|
const pcm = e.data?.pcm;
|
|
if (!(pcm instanceof Float32Array) || pcm.length === 0) return;
|
|
|
|
// If the incoming chunk would overflow the ring, drop the oldest data
|
|
if (pcm.length > RING_CAPACITY) return; // chunk larger than ring — skip
|
|
|
|
if (this._available + pcm.length > RING_CAPACITY) {
|
|
// Discard oldest samples to make room
|
|
const discard = this._available + pcm.length - RING_CAPACITY;
|
|
this._readPos = (this._readPos + discard) % RING_CAPACITY;
|
|
this._available -= discard;
|
|
}
|
|
|
|
// Write samples into ring buffer (may wrap)
|
|
let toWrite = pcm.length;
|
|
let src = 0;
|
|
while (toWrite > 0) {
|
|
const chunk = Math.min(toWrite, RING_CAPACITY - this._writePos);
|
|
this._ring.set(pcm.subarray(src, src + chunk), this._writePos);
|
|
this._writePos = (this._writePos + chunk) % RING_CAPACITY;
|
|
src += chunk;
|
|
toWrite -= chunk;
|
|
}
|
|
this._available += pcm.length;
|
|
};
|
|
}
|
|
|
|
process(_inputs, outputs) {
|
|
const output = outputs[0]?.[0];
|
|
if (!output) return true;
|
|
|
|
const count = Math.min(output.length, this._available);
|
|
|
|
if (count === 0) {
|
|
// Silence when buffer is empty
|
|
output.fill(0);
|
|
return true;
|
|
}
|
|
|
|
// Drain ring buffer into output (may wrap)
|
|
let toRead = count;
|
|
let dst = 0;
|
|
while (toRead > 0) {
|
|
const chunk = Math.min(toRead, RING_CAPACITY - this._readPos);
|
|
output.set(this._ring.subarray(this._readPos, this._readPos + chunk), dst);
|
|
this._readPos = (this._readPos + chunk) % RING_CAPACITY;
|
|
dst += chunk;
|
|
toRead -= chunk;
|
|
}
|
|
this._available -= count;
|
|
|
|
// Zero-pad if we ran short (shouldn't happen, but guard for safety)
|
|
if (count < output.length) {
|
|
output.fill(0, count);
|
|
}
|
|
|
|
return true; // keep processor alive
|
|
}
|
|
}
|
|
|
|
registerProcessor('playback-processor', PlaybackProcessor);
|