/** * A recorded trace as a .wav file. * * The trace holds one sample per timestep, 1/dt of them a second — around * 150 kHz at the default grid, which is a legal but eccentric rate for a * .wav. It is resampled to 48 kHz by linear interpolation, which is harmless * here: the content is band-limited far below either rate (the grid stops * resolving sound around 5 kHz). Normalized to peak, like playback, and * faded a few milliseconds at each end so the file does not open and close * with a click. */ export const WAV_RATE = 48000; export function traceToWav(trace: Float32Array, dt: number): Blob { const n = Math.max(1, Math.round(trace.length * dt * WAV_RATE)); let peak = 0; for (const v of trace) peak = Math.max(peak, Math.abs(v)); const gain = peak > 0 ? 0.9 / peak : 0; const samples = new Float32Array(n); for (let i = 0; i < n; i++) { const s = i / (WAV_RATE * dt); const k = Math.min(trace.length - 2, Math.floor(s)); const f = Math.min(1, s - k); samples[i] = gain * ((1 - f) * trace[k] + f * trace[k + 1]); } const fade = Math.min(Math.round(0.005 * WAV_RATE), Math.floor(n / 2)); for (let i = 0; i < fade; i++) { const w = i / fade; samples[i] *= w; samples[n - 1 - i] *= w; } const bytes = 44 + 2 * n; const buf = new ArrayBuffer(bytes); const view = new DataView(buf); const str = (off: number, s: string): void => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }; str(0, 'RIFF'); view.setUint32(4, bytes - 8, true); str(8, 'WAVE'); str(12, 'fmt '); view.setUint32(16, 16, true); // PCM chunk size view.setUint16(20, 1, true); // PCM view.setUint16(22, 1, true); // mono view.setUint32(24, WAV_RATE, true); view.setUint32(28, 2 * WAV_RATE, true); // byte rate view.setUint16(32, 2, true); // block align view.setUint16(34, 16, true); // bits per sample str(36, 'data'); view.setUint32(40, 2 * n, true); for (let i = 0; i < n; i++) { const v = Math.max(-1, Math.min(1, samples[i])); view.setInt16(44 + 2 * i, Math.round(v * 32767), true); } return new Blob([buf], { type: 'audio/wav' }); }