1/**
2 * Microphone capture, adapted from instrawave-compose. The browser's
3 * processing chain is asked to stand down: automatic gain control would
4 * rewrite the relative levels the benchmark deliberately randomizes, and
5 * noise suppression eats the sharp attacks that distinguish event types.
6 * Capture goes through MediaRecorder, is decoded to mono on stop, and is
7 * immediately reduced to the benchmark's internal rate with a box-filter
8 * average per output sample (a crude but adequate anti-alias for
9 * percussive material), so everything downstream sees one rate.
10 */
11export interface Clip {
12 samples: Float32Array;
13 sr: number;
14}
16/** Downsample by box-averaging the input samples covered by each output
17 * sample. Assumes srTo <= srFrom. */
18export function downsampleBox(x: Float32Array, srFrom: number, srTo: number): Float32Array {
19 if (srFrom === srTo) return x;
20 const n = Math.floor((x.length * srTo) / srFrom);
21 const out = new Float32Array(n);
22 for (let i = 0; i < n; i++) {
23 const a = Math.floor((i * srFrom) / srTo);
24 const b = Math.max(a + 1, Math.floor(((i + 1) * srFrom) / srTo));
25 let s = 0;
26 for (let j = a; j < b && j < x.length; j++) s += x[j];
27 out[i] = s / (b - a);
28 }
29 return out;
30}
32export class Recorder {
33 private stream: MediaStream | null = null;
34 private rec: MediaRecorder | null = null;
35 private chunks: Blob[] = [];
36 private ctx: AudioContext;
37 private analyser: AnalyserNode | null = null;
38 private levelBuf = new Float32Array(1024);
40 constructor(ctx: AudioContext) {
41 this.ctx = ctx;
42 }
44 get recording(): boolean {
45 return this.rec !== null;
46 }
48 async start(): Promise<void> {
49 this.stream = await navigator.mediaDevices.getUserMedia({
50 audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
51 });
52 if (this.ctx.state === 'suspended') await this.ctx.resume();
53 const src = this.ctx.createMediaStreamSource(this.stream);
54 this.analyser = this.ctx.createAnalyser();
55 this.analyser.fftSize = 1024;
56 src.connect(this.analyser); // analyser is a sink; nothing reaches the speakers
57 this.chunks = [];
58 this.rec = new MediaRecorder(this.stream);
59 this.rec.ondataavailable = (e) => { if (e.data.size) this.chunks.push(e.data); };
60 this.rec.start();
61 }
63 /** Instantaneous input level in [0, 1], for a meter. */
64 level(): number {
65 if (!this.analyser) return 0;
66 this.analyser.getFloatTimeDomainData(this.levelBuf);
67 let peak = 0;
68 for (let i = 0; i < this.levelBuf.length; i++) {
69 const a = Math.abs(this.levelBuf[i]);
70 if (a > peak) peak = a;
71 }
72 return Math.min(1, peak * 1.4);
73 }
75 /** Stop and return the clip at the requested rate. */
76 async stop(srTo: number): Promise<Clip> {
77 const rec = this.rec;
78 if (!rec) throw new Error('not recording');
79 this.rec = null;
80 const blob = await new Promise<Blob>((resolve) => {
81 rec.onstop = () => resolve(new Blob(this.chunks, { type: rec.mimeType }));
82 rec.stop();
83 });
84 this.stream?.getTracks().forEach((t) => t.stop());
85 this.stream = null;
86 this.analyser = null;
87 const buf = await this.ctx.decodeAudioData(await blob.arrayBuffer());
88 const mono = new Float32Array(buf.length);
89 for (let c = 0; c < buf.numberOfChannels; c++) {
90 const ch = buf.getChannelData(c);
91 for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
92 }
93 return { samples: downsampleBox(mono, buf.sampleRate, srTo), sr: srTo };
94 }
95}