/* * Voice recording for the patient's messages (#117, slide 11): microphone → 16 kHz mono 16-bit WAV in the browser, * so the server needs no ffmpeg and Chrome, Android and iOS Safari produce the same format (ADR-014). * The audio lives only in memory until it is sent for transcription; nothing is stored on the device. */ export const TARGET_RATE = 16_000; export const MAX_SECONDS = 60; type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext }; export function voiceSupported(): boolean { const w = window as WindowWithWebkit; return ( typeof navigator.mediaDevices?.getUserMedia === "function" && Boolean(window.AudioContext || w.webkitAudioContext) ); } export class Recorder { private ctx: AudioContext | null = null; private stream: MediaStream | null = null; private node: ScriptProcessorNode | null = null; private chunks: Float32Array[] = []; private rate = 48_000; /** `onLevel` gets the loudness 0..1 of each audio block (for the waveform). */ async start(onLevel: (level: number) => void): Promise { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); const Ctx = window.AudioContext || (window as WindowWithWebkit).webkitAudioContext!; this.ctx = new Ctx(); await this.ctx.resume(); this.rate = this.ctx.sampleRate; const source = this.ctx.createMediaStreamSource(this.stream); // ScriptProcessorNode is deprecated but works everywhere (incl. older iOS Safari) without a worklet file. this.node = this.ctx.createScriptProcessor(4096, 1, 1); this.node.onaudioprocess = (e) => { const data = new Float32Array(e.inputBuffer.getChannelData(0)); this.chunks.push(data); let sum = 0; for (let i = 0; i < data.length; i++) sum += data[i] * data[i]; onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4)); }; source.connect(this.node); this.node.connect(this.ctx.destination); } /** Stops the microphone and returns the WAV, or null if nothing was recorded. */ stop(): Blob | null { const chunks = this.chunks; const rate = this.rate; this.release(); if (!chunks.length) return null; return encodeWav(downsample(concat(chunks), rate, TARGET_RATE), TARGET_RATE); } cancel(): void { this.release(); } private release(): void { this.node?.disconnect(); if (this.node) this.node.onaudioprocess = null; this.stream?.getTracks().forEach((t) => t.stop()); void this.ctx?.close(); this.node = null; this.stream = null; this.ctx = null; this.chunks = []; } } function concat(chunks: Float32Array[]): Float32Array { const out = new Float32Array(chunks.reduce((n, c) => n + c.length, 0)); let pos = 0; for (const c of chunks) { out.set(c, pos); pos += c.length; } return out; } /** Averaging downsampler: good enough for speech, avoids aliasing better than dropping samples. */ function downsample(input: Float32Array, from: number, to: number): Float32Array { if (from === to) return input; const ratio = from / to; const out = new Float32Array(Math.floor(input.length / ratio)); for (let i = 0; i < out.length; i++) { const start = Math.floor(i * ratio); const end = Math.min(input.length, Math.floor((i + 1) * ratio)); let sum = 0; for (let j = start; j < end; j++) sum += input[j]; out[i] = sum / Math.max(1, end - start); } return out; } function encodeWav(samples: Float32Array, rate: number): Blob { const buf = new ArrayBuffer(44 + samples.length * 2); const v = new DataView(buf); const str = (o: number, s: string) => [...s].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0))); str(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); // PCM v.setUint16(22, 1, true); // mono v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); // byte rate v.setUint16(32, 2, true); // block align v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, samples.length * 2, true); for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } return new Blob([buf], { type: "audio/wav" }); }