/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / src / audio / recorder.ts
77 lines · 2.7 KBBlameHistoryRaw
1/**
2 * Microphone capture. The browser's processing chain is asked to stand
3 * down: automatic gain control would rewrite exactly the relative volumes
4 * this app exists to track, and noise suppression eats the breathy
5 * consonants ("tss") that distinguish voices. Capture goes through
6 * MediaRecorder and is decoded to mono float samples on stop; analysis is
7 * offline, so codec latency does not matter, and both training and takes
8 * pass through the identical path.
9 */
10export interface Clip {
11 samples: Float32Array;
12 sr: number;
15export class Recorder {
16 private stream: MediaStream | null = null;
17 private rec: MediaRecorder | null = null;
18 private chunks: Blob[] = [];
19 private ctx: AudioContext;
20 private analyser: AnalyserNode | null = null;
21 private levelBuf = new Float32Array(1024);
23 constructor(ctx: AudioContext) {
24 this.ctx = ctx;
25 }
27 get recording(): boolean {
28 return this.rec !== null;
29 }
31 async start(): Promise<void> {
32 this.stream = await navigator.mediaDevices.getUserMedia({
33 audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
34 });
35 if (this.ctx.state === 'suspended') await this.ctx.resume();
36 const src = this.ctx.createMediaStreamSource(this.stream);
37 this.analyser = this.ctx.createAnalyser();
38 this.analyser.fftSize = 1024;
39 src.connect(this.analyser); // analyser is a sink; nothing reaches the speakers
40 this.chunks = [];
41 this.rec = new MediaRecorder(this.stream);
42 this.rec.ondataavailable = (e) => { if (e.data.size) this.chunks.push(e.data); };
43 this.rec.start();
44 }
46 /** Instantaneous input level in [0, 1], for a meter. */
47 level(): number {
48 if (!this.analyser) return 0;
49 this.analyser.getFloatTimeDomainData(this.levelBuf);
50 let peak = 0;
51 for (let i = 0; i < this.levelBuf.length; i++) {
52 const a = Math.abs(this.levelBuf[i]);
53 if (a > peak) peak = a;
54 }
55 return Math.min(1, peak * 1.4);
56 }
58 async stop(): Promise<Clip> {
59 const rec = this.rec;
60 if (!rec) throw new Error('not recording');
61 this.rec = null;
62 const blob = await new Promise<Blob>((resolve) => {
63 rec.onstop = () => resolve(new Blob(this.chunks, { type: rec.mimeType }));
64 rec.stop();
65 });
66 this.stream?.getTracks().forEach((t) => t.stop());
67 this.stream = null;
68 this.analyser = null;
69 const buf = await this.ctx.decodeAudioData(await blob.arrayBuffer());
70 const samples = new Float32Array(buf.length);
71 for (let c = 0; c < buf.numberOfChannels; c++) {
72 const ch = buf.getChannelData(c);
73 for (let i = 0; i < buf.length; i++) samples[i] += ch[i] / buf.numberOfChannels;
74 }
75 return { samples, sr: buf.sampleRate };
76 }
↑↓ move↵openescclose