/** * Encode a mono signal as a PCM RIFF/WAVE file, 24-bit by default (as the * reference simulators write) or 16-bit. The input is expected to be * peak-normalized already (see `prepareAudio`); values outside [-1, 1] are * clipped rather than wrapped. Non-finite samples are written as silence. */ export function encodeWav(samples: ArrayLike, fs: number, bits: 16 | 24 = 24): Uint8Array { const n = samples.length; const bytesPerSample = bits / 8; const dataBytes = n * bytesPerSample; const buf = new ArrayBuffer(44 + dataBytes); const v = new DataView(buf); const ascii = (off: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); }; ascii(0, "RIFF"); v.setUint32(4, 36 + dataBytes, true); ascii(8, "WAVE"); ascii(12, "fmt "); v.setUint32(16, 16, true); // fmt chunk size v.setUint16(20, 1, true); // PCM v.setUint16(22, 1, true); // mono v.setUint32(24, fs, true); v.setUint32(28, fs * bytesPerSample, true); // byte rate v.setUint16(32, bytesPerSample, true); // block align v.setUint16(34, bits, true); // bits per sample ascii(36, "data"); v.setUint32(40, dataBytes, true); let off = 44; const full = bits === 24 ? 8388607 : 32767; for (let i = 0; i < n; i++, off += bytesPerSample) { const x = samples[i]; const c = Number.isFinite(x) ? Math.max(-1, Math.min(1, x)) : 0; const q = Math.round(c * full); if (bits === 24) { v.setUint8(off, q & 255); v.setUint8(off + 1, (q >> 8) & 255); v.setUint8(off + 2, (q >> 16) & 255); } else { v.setInt16(off, Math.max(-32768, q), true); } } return new Uint8Array(buf); }