const TARGET_SAMPLE_RATE = 16000; const CHUNK_SAMPLES = 3200; // 100ms at 16kHz export class AudioRecorder { private context: AudioContext | null = null; private stream: MediaStream | null = null; private workletNode: AudioWorkletNode | null = null; private accumulator: Float32Array = new Float32Array(0); micLevel = 0; async start(onChunk: (pcm: ArrayBuffer) => void): Promise { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, }, }); this.context = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE }); await this.context.resume(); await this.context.audioWorklet.addModule( new URL("./RecorderWorkletProcessor.js", import.meta.url).href ); const source = this.context.createMediaStreamSource(this.stream); this.workletNode = new AudioWorkletNode(this.context, "recorder-processor"); this.workletNode.port.onmessage = (e: MessageEvent) => { const input = e.data; const combined = new Float32Array(this.accumulator.length + input.length); combined.set(this.accumulator); combined.set(input, this.accumulator.length); this.accumulator = combined; while (this.accumulator.length >= CHUNK_SAMPLES) { const chunk = this.accumulator.slice(0, CHUNK_SAMPLES); this.accumulator = this.accumulator.slice(CHUNK_SAMPLES); // RMS for barge-in detection let sumSq = 0; for (let i = 0; i < chunk.length; i++) sumSq += chunk[i] * chunk[i]; this.micLevel = Math.sqrt(sumSq / chunk.length) * 32767; // Convert float32 → int16 PCM const int16 = new Int16Array(chunk.length); for (let i = 0; i < chunk.length; i++) { const s = Math.max(-1, Math.min(1, chunk[i])); int16[i] = s < 0 ? s * 32768 : s * 32767; } onChunk(int16.buffer); } }; source.connect(this.workletNode); } stop(): void { this.workletNode?.disconnect(); this.workletNode = null; this.stream?.getTracks().forEach((t) => t.stop()); this.stream = null; this.context?.close(); this.context = null; this.accumulator = new Float32Array(0); this.micLevel = 0; } }