companion/@applications/web/src/worklets/mic-processor.js

133 lines
3.9 KiB
JavaScript
Raw Normal View History

/**
* MicProcessor AudioWorkletProcessor
*
* Captures microphone audio at the browser's native sample rate,
* resamples to 16kHz mono via linear interpolation, and emits
* 960-sample Int16 frames (30ms at 16kHz) to the main thread.
*
* Wire format (ArrayBuffer):
* [0x01][seq: 4B big-endian][pcm: 960 * 2 bytes = 1920 bytes Int16]
* Total frame size: 1925 bytes
*/
const TARGET_SAMPLE_RATE = 16000;
const FRAME_SAMPLES = 960; // 30ms at 16kHz
const HEADER_BYTES = 5; // 1 type byte + 4 seq bytes
const FRAME_BYTES = FRAME_SAMPLES * 2; // Int16 = 2 bytes per sample
const BUFFER_BYTES = HEADER_BYTES + FRAME_BYTES;
class MicProcessor extends AudioWorkletProcessor {
constructor() {
super();
/** Resampled PCM accumulation buffer */
this._accumulator = new Float32Array(FRAME_SAMPLES * 2);
/** Write position in accumulator */
this._accPos = 0;
/** Fractional sample position for linear interpolation resampling */
this._resamplePhase = 0.0;
/** Per-frame sequence counter (uint32, wraps) */
this._seq = 0;
/** Ratio: input samples per output sample */
this._ratio = 0;
}
/**
* Linear interpolation resampler.
* Takes a block of float32 input samples (at native rate) and appends
* resampled float32 output samples (at 16kHz) into the accumulator,
* flushing complete 960-sample frames to the main thread.
*
* @param {Float32Array} input - Input samples at native sample rate
*/
_resampleAndAccumulate(input) {
const inputLen = input.length;
if (inputLen === 0) return;
// Compute ratio on first real input block (sampleRate is available in worklet)
if (this._ratio === 0) {
this._ratio = sampleRate / TARGET_SAMPLE_RATE;
}
const ratio = this._ratio;
let phase = this._resamplePhase;
while (phase < inputLen) {
const i0 = Math.floor(phase);
const i1 = Math.min(i0 + 1, inputLen - 1);
const frac = phase - i0;
const s0 = input[i0];
const s1 = input[i1];
const sample = s0 + frac * (s1 - s0);
this._accumulator[this._accPos++] = sample;
if (this._accPos === FRAME_SAMPLES) {
this._flushFrame();
this._accPos = 0;
}
phase += ratio;
}
// Carry over fractional phase (subtract consumed integer samples)
this._resamplePhase = phase - inputLen;
}
/**
* Encode and post a complete 960-sample frame.
*/
_flushFrame() {
const buffer = new ArrayBuffer(BUFFER_BYTES);
const view = new DataView(buffer);
// Header
view.setUint8(0, 0x01);
view.setUint32(1, this._seq, false); // big-endian
// PCM: Float32 → Int16 with clamping.
// Int16Array requires 2-byte-aligned offsets; HEADER_BYTES=5 is odd, so use a
// separate aligned buffer and copy the bytes in with Uint8Array.
const pcmTemp = new Int16Array(FRAME_SAMPLES);
for (let i = 0; i < FRAME_SAMPLES; i++) {
const f = this._accumulator[i];
const clamped = f < -1.0 ? -1.0 : f > 1.0 ? 1.0 : f;
pcmTemp[i] = Math.round(clamped * 32767);
}
new Uint8Array(buffer, HEADER_BYTES).set(new Uint8Array(pcmTemp.buffer));
this.port.postMessage(buffer, [buffer]);
this._seq = (this._seq + 1) >>> 0; // unsigned 32-bit increment
}
/**
* @param {Float32Array[][]} inputs
* @returns {boolean}
*/
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0) return true;
// Mix down to mono if stereo
const ch0 = input[0];
if (!ch0 || ch0.length === 0) return true;
let mono;
if (input.length > 1 && input[1] && input[1].length === ch0.length) {
mono = new Float32Array(ch0.length);
const ch1 = input[1];
for (let i = 0; i < ch0.length; i++) {
mono[i] = (ch0[i] + ch1[i]) * 0.5;
}
} else {
mono = ch0;
}
this._resampleAndAccumulate(mono);
return true;
}
}
registerProcessor('mic-processor', MicProcessor);