1035139A plucked dulcimer string and its box, as two coupled wave equations on WebGPUJeremy Magland 1/**
2 * Turning a recorded trace into something you can hear.
3 *
4 * Because the solver is in real SI units, `dt` is a real duration in seconds
5 * — so the natural playback rate is just 1/dt, sample for sample. Nothing is
6 * reinterpreted: the trace plays back at the same real-time pace, and the
7 * same pitch, that a microphone sitting at the probe point would have heard.
8 * This is more than a convenience. A Courant-limited timestep on a grid fine
9 * enough to resolve audible frequencies lands, by construction, in the same
10 * range as an audio sample rate — the app's default settings give dt around
11 * 20 microseconds, a rate near 48 kHz, which is not a coincidence: both are
12 * set by "resolve a few centimetres of wave at audio frequency."
13 *
14 * `dt` can still land outside what a browser's AudioContext will accept, at
15 * an unusual grid or CFL setting, so the rate is clamped and the caller is
16 * told when that happened — the trace then plays sped up or slowed down
17 * rather than at its real pace, which is worth knowing rather than hiding.
18 *
19 * Nothing here is a physical claim about loudness. The trace is normalized so
20 * that whatever was recorded is audible, which discards exactly the quantity
21 * (absolute amplitude) that the colour scale already shows.
22 */
24/** What a browser will accept as an AudioBuffer sample rate. The spec's range
25 * is wider than any of this needs; these bounds keep the derived rate inside
26 * what every implementation supports. */
27const MIN_RATE = 8000;
28const MAX_RATE = 192000;
30export interface PlaybackPlan {
31 /** Samples per second the trace is played back at. */
32 rate: number;
33 /** Seconds of audio. */
34 duration: number;
35 /** True if `rate` is the real 1/dt — false if it had to be clamped, in
36 * which case playback runs faster or slower than the simulation did. */
37 realTime: boolean;
38}
40/** How a recorded trace of `samples` taken at timestep `dt` would be played. */
41export function planPlayback(samples: number, dt: number): PlaybackPlan {
42 const wanted = 1 / Math.max(dt, 1e-12);
43 const rate = Math.min(MAX_RATE, Math.max(MIN_RATE, wanted));
44 return { rate, duration: samples / rate, realTime: rate === wanted };
45}
47let context: AudioContext | null = null;
48let playing: AudioBufferSourceNode | null = null;
50/**
51 * Play a recorded trace. Returns what was actually played, so the caller can
52 * report it.
53 *
54 * Normalized to peak amplitude, and with a few milliseconds of fade at each
55 * end: a trace that starts or ends away from zero is a step, and a step is a
56 * click that has nothing to do with the simulation.
57 */
58export async function playTrace(
59 trace: Float32Array,
60 plan: PlaybackPlan,
61): Promise<PlaybackPlan> {
62 if (trace.length === 0) throw new Error('nothing has been recorded yet');
63 context ??= new AudioContext();
64 if (context.state === 'suspended') await context.resume();
66 const buffer = context.createBuffer(1, trace.length, plan.rate);
67 const channel = buffer.getChannelData(0);
68 let peak = 0;
69 for (const v of trace) peak = Math.max(peak, Math.abs(v));
70 const gain = peak > 0 ? 0.9 / peak : 0;
71 for (let i = 0; i < trace.length; i++) channel[i] = trace[i] * gain;
73 const fade = Math.min(Math.round(0.005 * plan.rate), Math.floor(trace.length / 2));
74 for (let i = 0; i < fade; i++) {
75 const w = i / fade;
76 channel[i] *= w;
77 channel[trace.length - 1 - i] *= w;
78 }
80 stop();
81 const source = context.createBufferSource();
82 source.buffer = buffer;
83 source.connect(context.destination);
84 source.onended = () => {
85 if (playing === source) playing = null;
86 };
87 source.start();
88 playing = source;
89 return plan;
90}
92export function stop(): void {
93 if (!playing) return;
94 playing.stop();
95 playing = null;
96}
98export const isPlaying = (): boolean => playing !== null;