159 lines
4.5 KiB
JavaScript
159 lines
4.5 KiB
JavaScript
/**
|
|
* PcmPlayerProcessor AudioWorkletProcessor
|
|
*
|
|
* Receives Int16 PCM frames at 22050Hz mono from the main thread,
|
|
* converts to Float32, buffers in a ring buffer, and feeds the
|
|
* Web Audio output at the destination's native sample rate.
|
|
*
|
|
* If the destination's sample rate differs from 22050Hz, linear
|
|
* interpolation resampling is applied on output.
|
|
*
|
|
* Ring buffer behaviour:
|
|
* - Underrun: output silence, do not stall
|
|
* - Overrun: drop oldest frame to make room
|
|
*
|
|
* Main thread → worklet message:
|
|
* { type: 'enqueue', buffer: ArrayBuffer } — raw Int16 PCM bytes
|
|
* { type: 'flush' } — clear ring buffer
|
|
*
|
|
* Wire format expected (header already stripped by PcmPlayer.ts):
|
|
* Int16 PCM samples at 22050Hz mono
|
|
*/
|
|
|
|
const INPUT_SAMPLE_RATE = 22050;
|
|
/** Ring buffer capacity in samples (≈1.5 seconds at 22050Hz) */
|
|
const RING_CAPACITY = 32768; // must be power of 2
|
|
|
|
class PcmPlayerProcessor extends AudioWorkletProcessor {
|
|
constructor() {
|
|
super();
|
|
|
|
/** Float32 ring buffer */
|
|
this._ring = new Float32Array(RING_CAPACITY);
|
|
/** Write head (next write position) */
|
|
this._writeHead = 0;
|
|
/** Read head (next read position) */
|
|
this._readHead = 0;
|
|
/** Number of valid samples in ring buffer */
|
|
this._available = 0;
|
|
|
|
/** Output resampling state */
|
|
this._outPhase = 0.0;
|
|
this._outRatio = 0; // set on first process() call
|
|
|
|
this.port.onmessage = (event) => {
|
|
const msg = event.data;
|
|
if (msg.type === 'enqueue' && msg.buffer instanceof ArrayBuffer) {
|
|
this._enqueue(msg.buffer);
|
|
} else if (msg.type === 'flush') {
|
|
this._flush();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enqueue raw Int16 PCM bytes into the ring buffer.
|
|
* @param {ArrayBuffer} buffer
|
|
*/
|
|
_enqueue(buffer) {
|
|
const int16 = new Int16Array(buffer);
|
|
const incoming = int16.length;
|
|
|
|
// Check for overrun — drop oldest samples to make room
|
|
const freeSpace = RING_CAPACITY - this._available;
|
|
if (incoming > freeSpace) {
|
|
const drop = incoming - freeSpace;
|
|
this._readHead = (this._readHead + drop) & (RING_CAPACITY - 1);
|
|
this._available -= drop;
|
|
}
|
|
|
|
// Write Int16 → Float32 into ring
|
|
for (let i = 0; i < incoming; i++) {
|
|
this._ring[this._writeHead] = int16[i] / 32768.0;
|
|
this._writeHead = (this._writeHead + 1) & (RING_CAPACITY - 1);
|
|
}
|
|
this._available += incoming;
|
|
}
|
|
|
|
/**
|
|
* Clear ring buffer (e.g. when switching utterance or stopping).
|
|
*/
|
|
_flush() {
|
|
this._writeHead = 0;
|
|
this._readHead = 0;
|
|
this._available = 0;
|
|
this._outPhase = 0.0;
|
|
}
|
|
|
|
/**
|
|
* Read one sample from the ring buffer at the current outPhase,
|
|
* using linear interpolation between adjacent ring samples.
|
|
* Returns 0.0 (silence) on underrun.
|
|
*
|
|
* Advances outPhase by outRatio.
|
|
*
|
|
* @returns {number} Float32 output sample
|
|
*/
|
|
_readResampledSample() {
|
|
if (this._available < 2) {
|
|
// Underrun — return silence, don't advance phase
|
|
return 0.0;
|
|
}
|
|
|
|
const phase = this._outPhase;
|
|
const i0 = Math.floor(phase);
|
|
const frac = phase - i0;
|
|
|
|
// i0 and i0+1 relative to readHead
|
|
const idx0 = (this._readHead + i0) & (RING_CAPACITY - 1);
|
|
const idx1 = (this._readHead + i0 + 1) & (RING_CAPACITY - 1);
|
|
|
|
const s0 = this._ring[idx0];
|
|
const s1 = this._ring[idx1];
|
|
const sample = s0 + frac * (s1 - s0);
|
|
|
|
this._outPhase += this._outRatio;
|
|
|
|
// Consume integer samples from ring when phase crosses whole numbers
|
|
const consumed = Math.floor(this._outPhase);
|
|
if (consumed > 0) {
|
|
const actualConsume = Math.min(consumed, this._available - 1);
|
|
this._readHead = (this._readHead + actualConsume) & (RING_CAPACITY - 1);
|
|
this._available -= actualConsume;
|
|
this._outPhase -= actualConsume;
|
|
}
|
|
|
|
return sample;
|
|
}
|
|
|
|
/**
|
|
* @param {Float32Array[][]} _inputs
|
|
* @param {Float32Array[][]} outputs
|
|
* @returns {boolean}
|
|
*/
|
|
process(_inputs, outputs) {
|
|
if (this._outRatio === 0) {
|
|
// sampleRate is the AudioContext's output rate
|
|
this._outRatio = INPUT_SAMPLE_RATE / sampleRate;
|
|
}
|
|
|
|
const output = outputs[0];
|
|
if (!output || output.length === 0) return true;
|
|
|
|
const ch = output[0];
|
|
if (!ch) return true;
|
|
|
|
for (let i = 0; i < ch.length; i++) {
|
|
ch[i] = this._readResampledSample();
|
|
}
|
|
|
|
// If stereo output, copy left to right
|
|
if (output.length > 1 && output[1]) {
|
|
output[1].set(ch);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor('pcm-player', PcmPlayerProcessor);
|