/** * Playing the microphone trace. * * The trace holds one sample per timestep, dt seconds apart, so its native * sample rate is 1/dt — around 190 kHz at the default settings, which is * beyond what an AudioBuffer must accept. The trace is resampled to 48 kHz by * linear interpolation, which is harmless here: the content is band-limited * far below either rate (the source tops out at 4 kHz). * * The AudioContext is created once and resumed on every play. Browsers start * a context suspended unless it is created directly inside a user gesture, * and the Listen click's gesture is over by the time the trace has come back * from the GPU — resuming is what makes playback actually sound. This is the * bug the first version of this file had: it played, silently, into a * suspended context. * * The recording is normalized before playback, which discards absolute * amplitude: that is what the colour scale is for. A few milliseconds of fade * are applied at each end — a trace that starts or ends away from zero is a * step, and a step is a click that has nothing to do with the simulation. */ let context: AudioContext | null = null; let playing: AudioBufferSourceNode | null = null; export interface Played { /** Seconds of audio actually played. */ duration: number; /** Peak |p| of the trace before normalization. Zero means silence went by. */ peak: number; } export async function playTrace(samples: Float32Array, dt: number): Promise { const rate = 48000; const n = Math.max(1, Math.round(samples.length * dt * rate)); let peak = 0; for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i])); const g = peak > 0 ? 0.9 / peak : 0; context ??= new AudioContext(); if (context.state === 'suspended') await context.resume(); const buf = context.createBuffer(1, n, rate); const ch = buf.getChannelData(0); for (let i = 0; i < n; i++) { const s = i / (rate * dt); const k = Math.min(samples.length - 2, Math.floor(s)); const f = Math.min(1, s - k); ch[i] = g * ((1 - f) * samples[k] + f * samples[k + 1]); } const fade = Math.min(Math.round(0.005 * rate), Math.floor(n / 2)); for (let i = 0; i < fade; i++) { const w = i / fade; ch[i] *= w; ch[n - 1 - i] *= w; } if (playing) playing.stop(); const src = context.createBufferSource(); src.buffer = buf; src.connect(context.destination); src.onended = () => { if (playing === src) playing = null; }; src.start(); playing = src; return { duration: n / rate, peak }; }