companion/@applications/web/src/worklets/pcm-player.js
Claude Code 0bc056d211 arch(applications): 🏗️ Refactor application imports and file structure
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-01 23:54:15 -07:00

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);