/** * Live microphone capture → onset stream. * * Web Audio graph: * getUserMedia → MediaStreamSource → BiquadFilter(highpass) → * ScriptProcessor → (muted gain → destination, just to keep it pumping) * * The ScriptProcessor receives already-high-passed blocks and feeds them to * the StreamingOnsetDetector (lib/detector.js). Each detected onset is * reported with a session-relative timestamp (ms) suitable for wire.js * decoding. A level callback drives the visualizer. * * Browser audio "enhancements" (echo cancellation, noise suppression, auto * gain) are explicitly DISABLED — they treat sharp clicks as noise and would * gut exactly the signal we need. */ import { StreamingOnsetDetector } from "./detector.js"; export class Mic { /** * @param {object} o * @param {(timeMs:number, strength:number)=>void} o.onOnset * @param {(level:number, wave:Float32Array)=>void} [o.onLevel] * @param {number} [o.highpassHz] * @param {object} [o.detector] extra StreamingOnsetDetector options */ constructor(o) { this.opts = o; this.highpassHz = o.highpassHz ?? 2000; this.ctx = null; this.stream = null; this._nodes = []; this._detector = null; this._elapsedMs = 0; this.running = false; } async start() { const AC = window.AudioContext || window.webkitAudioContext; this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1, }, }); this.ctx = new AC(); if (this.ctx.state === "suspended") await this.ctx.resume(); const sr = this.ctx.sampleRate; const src = this.ctx.createMediaStreamSource(this.stream); const hp = this.ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = this.highpassHz; hp.Q.value = Math.SQRT1_2; const bufferSize = 2048; const proc = this.ctx.createScriptProcessor(bufferSize, 1, 1); const mute = this.ctx.createGain(); mute.gain.value = 0; this._detector = new StreamingOnsetDetector({ sampleRate: sr, onOnset: (timeMs, strength) => this.opts.onOnset?.(timeMs, strength), ...(this.opts.detector || {}), }); this._elapsedMs = 0; this.running = true; proc.onaudioprocess = (e) => { if (!this.running) return; const block = e.inputBuffer.getChannelData(0); const base = this._elapsedMs; this._detector.process(block, base); this._elapsedMs += (block.length / sr) * 1000; if (this.opts.onLevel) { let sum = 0; for (let i = 0; i < block.length; i++) sum += block[i] * block[i]; this.opts.onLevel(Math.sqrt(sum / block.length), block); } }; src.connect(hp); hp.connect(proc); proc.connect(mute); mute.connect(this.ctx.destination); this._nodes = [src, hp, proc, mute]; } /** Current session clock (ms since start) — handy to time out a message. */ now() { return this._elapsedMs; } async stop() { this.running = false; this._nodes.forEach((n) => { try { n.disconnect(); } catch { /* */ } }); this._nodes = []; if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; } if (this.ctx) { try { await this.ctx.close(); } catch { /* */ } this.ctx = null; } } }