// Minimal, dependency-free WAV (RIFF/WAVE) codec for PCM audio. // // Supports decoding: // - 16-bit integer PCM (fmt audioFormat === 1) // - 32-bit IEEE float PCM (fmt audioFormat === 3) // Chunks may appear in any order; unknown chunks are skipped. Channels are // de-interleaved into one Float32Array per channel, with samples in [-1, 1]. // // Encoding produces a mono, 16-bit PCM WAVE file. // // All multi-byte header fields are little-endian (per the WAVE spec). We use // DataView so endianness is explicit and never relies on the host platform. /** A decoded WAVE file: sample rate plus one Float32Array per channel. */ export interface DecodedWav { sampleRate: number; /** One entry per channel; each holds that channel's samples in [-1, 1]. */ channelData: Float32Array[]; } const WAVE_FORMAT_PCM = 1; const WAVE_FORMAT_IEEE_FLOAT = 3; /** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */ function readTag(view: DataView, offset: number): string { return ( String.fromCharCode(view.getUint8(offset)) + String.fromCharCode(view.getUint8(offset + 1)) + String.fromCharCode(view.getUint8(offset + 2)) + String.fromCharCode(view.getUint8(offset + 3)) ); } /** * Parse a RIFF/WAVE byte buffer. * * @throws if the buffer is too small, is not a RIFF container, is not a WAVE * form, lacks fmt/data chunks, or uses an unsupported sample format. */ export function decodeWav(bytes: Uint8Array): DecodedWav { // A valid WAVE needs at least the 12-byte RIFF/WAVE descriptor. if (bytes.byteLength < 12) { throw new Error('Invalid WAV: file too small to contain a RIFF header'); } const view = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength, ); if (readTag(view, 0) !== 'RIFF') { throw new Error("Invalid WAV: missing 'RIFF' magic"); } if (readTag(view, 8) !== 'WAVE') { throw new Error("Invalid WAV: not a 'WAVE' file"); } let audioFormat = -1; let numChannels = 0; let sampleRate = 0; let bitsPerSample = 0; let dataOffset = -1; let dataLength = 0; let haveFmt = false; let haveData = false; // Walk the chunk list starting right after the 12-byte descriptor. let offset = 12; while (offset + 8 <= bytes.byteLength) { const chunkId = readTag(view, offset); const chunkSize = view.getUint32(offset + 4, true); const chunkBody = offset + 8; if (chunkId === 'fmt ') { if (chunkBody + 16 > bytes.byteLength) { throw new Error('Invalid WAV: truncated fmt chunk'); } audioFormat = view.getUint16(chunkBody, true); numChannels = view.getUint16(chunkBody + 2, true); sampleRate = view.getUint32(chunkBody + 4, true); // bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored) bitsPerSample = view.getUint16(chunkBody + 14, true); haveFmt = true; } else if (chunkId === 'data') { dataOffset = chunkBody; // Clamp to the actual buffer in case the declared size overruns it. dataLength = Math.min(chunkSize, bytes.byteLength - chunkBody); haveData = true; } // Unknown chunks fall through and are skipped. // Chunks are word-aligned: an odd size is followed by a pad byte. let advance = chunkSize; if (advance % 2 === 1) advance += 1; offset = chunkBody + advance; } if (!haveFmt) { throw new Error("Invalid WAV: missing 'fmt ' chunk"); } if (!haveData) { throw new Error("Invalid WAV: missing 'data' chunk"); } if (numChannels < 1) { throw new Error(`Invalid WAV: bad channel count ${numChannels}`); } const channelData: Float32Array[] = []; if (audioFormat === WAVE_FORMAT_PCM && bitsPerSample === 16) { const bytesPerSample = 2; const frameSize = bytesPerSample * numChannels; const numFrames = Math.floor(dataLength / frameSize); for (let c = 0; c < numChannels; c++) { channelData.push(new Float32Array(numFrames)); } for (let frame = 0; frame < numFrames; frame++) { const base = dataOffset + frame * frameSize; for (let c = 0; c < numChannels; c++) { const int16 = view.getInt16(base + c * bytesPerSample, true); // Asymmetric int16 range: divide negatives by 32768, positives by 32767. const f = int16 < 0 ? int16 / 0x8000 : int16 / 0x7fff; channelData[c]![frame] = f; } } } else if ( audioFormat === WAVE_FORMAT_IEEE_FLOAT && bitsPerSample === 32 ) { const bytesPerSample = 4; const frameSize = bytesPerSample * numChannels; const numFrames = Math.floor(dataLength / frameSize); for (let c = 0; c < numChannels; c++) { channelData.push(new Float32Array(numFrames)); } for (let frame = 0; frame < numFrames; frame++) { const base = dataOffset + frame * frameSize; for (let c = 0; c < numChannels; c++) { channelData[c]![frame] = view.getFloat32( base + c * bytesPerSample, true, ); } } } else { throw new Error( `Unsupported WAV format: audioFormat=${audioFormat}, ` + `bitsPerSample=${bitsPerSample} (supported: 16-bit PCM, 32-bit float)`, ); } return { sampleRate, channelData }; } /** * Encode mono samples (in [-1, 1]) into a 16-bit PCM WAVE file. * Out-of-range samples are clamped before quantization. */ export function encodeWav( samples: Float32Array, sampleRate: number, ): Uint8Array { const numChannels = 1; const bitsPerSample = 16; const bytesPerSample = bitsPerSample / 8; const blockAlign = numChannels * bytesPerSample; const byteRate = sampleRate * blockAlign; const dataSize = samples.length * bytesPerSample; const headerSize = 44; const totalSize = headerSize + dataSize; const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); const writeTag = (offset: number, tag: string): void => { for (let i = 0; i < tag.length; i++) { view.setUint8(offset + i, tag.charCodeAt(i)); } }; // RIFF descriptor writeTag(0, 'RIFF'); view.setUint32(4, totalSize - 8, true); // chunkSize = file size minus first 8 writeTag(8, 'WAVE'); // fmt chunk (16 bytes of PCM format data) writeTag(12, 'fmt '); view.setUint32(16, 16, true); // fmt chunk body size view.setUint16(20, WAVE_FORMAT_PCM, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bitsPerSample, true); // data chunk writeTag(36, 'data'); view.setUint32(40, dataSize, true); let offset = headerSize; for (let i = 0; i < samples.length; i++) { let s = samples[i]!; // Clamp to [-1, 1] to avoid integer overflow/wraparound on quantize. if (s > 1) s = 1; else if (s < -1) s = -1; // Match the asymmetric int16 range used when decoding. const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff); view.setInt16(offset, int16, true); offset += bytesPerSample; } return new Uint8Array(buffer); }