/** * Turning a recorded trace into something you can hear. * * Because the solver is in real SI units, `dt` is a real duration in seconds * — so the natural playback rate is just 1/dt, sample for sample. Nothing is * reinterpreted: the trace plays back at the same real-time pace, and the * same pitch, that a microphone sitting at the probe point would have heard. * This is more than a convenience. A Courant-limited timestep on a grid fine * enough to resolve audible frequencies lands, by construction, in the same * range as an audio sample rate — the app's default settings give dt around * 20 microseconds, a rate near 48 kHz, which is not a coincidence: both are * set by "resolve a few centimetres of wave at audio frequency." * * `dt` can still land outside what a browser's AudioContext will accept, at * an unusual grid or CFL setting, so the rate is clamped and the caller is * told when that happened — the trace then plays sped up or slowed down * rather than at its real pace, which is worth knowing rather than hiding. * * Nothing here is a physical claim about loudness. The trace is normalized so * that whatever was recorded is audible, which discards exactly the quantity * (absolute amplitude) that the colour scale already shows. */ /** What a browser will accept as an AudioBuffer sample rate. The spec's range * is wider than any of this needs; these bounds keep the derived rate inside * what every implementation supports. */ const MIN_RATE = 8000; const MAX_RATE = 192000; export interface PlaybackPlan { /** Samples per second the trace is played back at. */ rate: number; /** Seconds of audio. */ duration: number; /** True if `rate` is the real 1/dt — false if it had to be clamped, in * which case playback runs faster or slower than the simulation did. */ realTime: boolean; } /** How a recorded trace of `samples` taken at timestep `dt` would be played. */ export function planPlayback(samples: number, dt: number): PlaybackPlan { const wanted = 1 / Math.max(dt, 1e-12); const rate = Math.min(MAX_RATE, Math.max(MIN_RATE, wanted)); return { rate, duration: samples / rate, realTime: rate === wanted }; } let context: AudioContext | null = null; let playing: AudioBufferSourceNode | null = null; /** * Play a recorded trace. Returns what was actually played, so the caller can * report it. * * Normalized to peak amplitude, and with a few milliseconds of fade 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. */ export async function playTrace( trace: Float32Array, plan: PlaybackPlan, ): Promise { if (trace.length === 0) throw new Error('nothing has been recorded yet'); context ??= new AudioContext(); if (context.state === 'suspended') await context.resume(); const buffer = context.createBuffer(1, trace.length, plan.rate); const channel = buffer.getChannelData(0); let peak = 0; for (const v of trace) peak = Math.max(peak, Math.abs(v)); const gain = peak > 0 ? 0.9 / peak : 0; for (let i = 0; i < trace.length; i++) channel[i] = trace[i] * gain; const fade = Math.min(Math.round(0.005 * plan.rate), Math.floor(trace.length / 2)); for (let i = 0; i < fade; i++) { const w = i / fade; channel[i] *= w; channel[trace.length - 1 - i] *= w; } stop(); const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); source.onended = () => { if (playing === source) playing = null; }; source.start(); playing = source; return plan; } export function stop(): void { if (!playing) return; playing.stop(); playing = null; } export const isPlaying = (): boolean => playing !== null;