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