/** * Microphone capture, adapted from instrawave-compose. The browser's * processing chain is asked to stand down: automatic gain control would * rewrite the relative levels the benchmark deliberately randomizes, and * noise suppression eats the sharp attacks that distinguish event types. * Capture goes through MediaRecorder, is decoded to mono on stop, and is * immediately reduced to the benchmark's internal rate with a box-filter * average per output sample (a crude but adequate anti-alias for * percussive material), so everything downstream sees one rate. */ export interface Clip { samples: Float32Array; sr: number; } /** Downsample by box-averaging the input samples covered by each output * sample. Assumes srTo <= srFrom. */ export function downsampleBox(x: Float32Array, srFrom: number, srTo: number): Float32Array { if (srFrom === srTo) return x; const n = Math.floor((x.length * srTo) / srFrom); const out = new Float32Array(n); for (let i = 0; i < n; i++) { const a = Math.floor((i * srFrom) / srTo); const b = Math.max(a + 1, Math.floor(((i + 1) * srFrom) / srTo)); let s = 0; for (let j = a; j < b && j < x.length; j++) s += x[j]; out[i] = s / (b - a); } return out; } export class Recorder { private stream: MediaStream | null = null; private rec: MediaRecorder | null = null; private chunks: Blob[] = []; private ctx: AudioContext; private analyser: AnalyserNode | null = null; private levelBuf = new Float32Array(1024); constructor(ctx: AudioContext) { this.ctx = ctx; } get recording(): boolean { return this.rec !== null; } async start(): Promise { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, }); if (this.ctx.state === 'suspended') await this.ctx.resume(); const src = this.ctx.createMediaStreamSource(this.stream); this.analyser = this.ctx.createAnalyser(); this.analyser.fftSize = 1024; src.connect(this.analyser); // analyser is a sink; nothing reaches the speakers this.chunks = []; this.rec = new MediaRecorder(this.stream); this.rec.ondataavailable = (e) => { if (e.data.size) this.chunks.push(e.data); }; this.rec.start(); } /** Instantaneous input level in [0, 1], for a meter. */ level(): number { if (!this.analyser) return 0; this.analyser.getFloatTimeDomainData(this.levelBuf); let peak = 0; for (let i = 0; i < this.levelBuf.length; i++) { const a = Math.abs(this.levelBuf[i]); if (a > peak) peak = a; } return Math.min(1, peak * 1.4); } /** Stop and return the clip at the requested rate. */ async stop(srTo: number): Promise { const rec = this.rec; if (!rec) throw new Error('not recording'); this.rec = null; const blob = await new Promise((resolve) => { rec.onstop = () => resolve(new Blob(this.chunks, { type: rec.mimeType })); rec.stop(); }); this.stream?.getTracks().forEach((t) => t.stop()); this.stream = null; this.analyser = null; const buf = await this.ctx.decodeAudioData(await blob.arrayBuffer()); const mono = new Float32Array(buf.length); for (let c = 0; c < buf.numberOfChannels; c++) { const ch = buf.getChannelData(c); for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels; } return { samples: downsampleBox(mono, buf.sampleRate, srTo), sr: srTo }; } }