1/**
2 * Playing the microphone trace.
3 *
4 * The trace holds one sample per timestep, dt seconds apart, so its native
5 * sample rate is 1/dt — around 190 kHz at the default settings, which is
6 * beyond what an AudioBuffer must accept. The trace is resampled to 48 kHz by
7 * linear interpolation, which is harmless here: the content is band-limited
8 * far below either rate (the source tops out at 4 kHz).
9 *
10 * The AudioContext is created once and resumed on every play. Browsers start
11 * a context suspended unless it is created directly inside a user gesture,
12 * and the Listen click's gesture is over by the time the trace has come back
13 * from the GPU — resuming is what makes playback actually sound. This is the
14 * bug the first version of this file had: it played, silently, into a
15 * suspended context.
16 *
17 * The recording is normalized before playback, which discards absolute
18 * amplitude: that is what the colour scale is for. A few milliseconds of fade
19 * are applied at each end — a trace that starts or ends away from zero is a
20 * step, and a step is a click that has nothing to do with the simulation.
21 */
23let context: AudioContext | null = null;
24let playing: AudioBufferSourceNode | null = null;
26export interface Played {
27 /** Seconds of audio actually played. */
28 duration: number;
29 /** Peak |p| of the trace before normalization. Zero means silence went by. */
30 peak: number;
31}
33export async function playTrace(samples: Float32Array, dt: number): Promise<Played> {
34 const rate = 48000;
35 const n = Math.max(1, Math.round(samples.length * dt * rate));
37 let peak = 0;
38 for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
39 const g = peak > 0 ? 0.9 / peak : 0;
41 context ??= new AudioContext();
42 if (context.state === 'suspended') await context.resume();
44 const buf = context.createBuffer(1, n, rate);
45 const ch = buf.getChannelData(0);
46 for (let i = 0; i < n; i++) {
47 const s = i / (rate * dt);
48 const k = Math.min(samples.length - 2, Math.floor(s));
49 const f = Math.min(1, s - k);
50 ch[i] = g * ((1 - f) * samples[k] + f * samples[k + 1]);
51 }
52 const fade = Math.min(Math.round(0.005 * rate), Math.floor(n / 2));
53 for (let i = 0; i < fade; i++) {
54 const w = i / fade;
55 ch[i] *= w;
56 ch[n - 1 - i] *= w;
57 }
59 if (playing) playing.stop();
60 const src = context.createBufferSource();
61 src.buffer = buf;
62 src.connect(context.destination);
63 src.onended = () => {
64 if (playing === src) playing = null;
65 };
66 src.start();
67 playing = src;
68 return { duration: n / rate, peak };
69}