/** * Streaming WAV file writer — opens a file, writes a placeholder header, * appends raw PCM data in chunks, and finalizes (patches sizes) on close. * * Produces standard RIFF/WAVE format compatible with the WAV parser * in announcement.ts (extractPcmFromWav). */ import fs from 'node:fs'; import { Buffer } from 'node:buffer'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface IWavWriterOptions { /** Full path to the output WAV file. */ filePath: string; /** Sample rate in Hz (e.g. 16000). */ sampleRate: number; /** Number of channels (default 1 = mono). */ channels?: number; /** Bits per sample (default 16). */ bitsPerSample?: number; } export interface IWavWriterResult { /** Full path to the WAV file. */ filePath: string; /** Total duration in milliseconds. */ durationMs: number; /** Sample rate of the output file. */ sampleRate: number; /** Total number of audio samples written. */ totalSamples: number; /** File size in bytes. */ fileSize: number; } // --------------------------------------------------------------------------- // WAV header constants // --------------------------------------------------------------------------- /** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */ const HEADER_SIZE = 44; // --------------------------------------------------------------------------- // WavWriter // --------------------------------------------------------------------------- export class WavWriter { private fd: number | null = null; private totalDataBytes = 0; private closed = false; private filePath: string; private sampleRate: number; private channels: number; private bitsPerSample: number; constructor(options: IWavWriterOptions) { this.filePath = options.filePath; this.sampleRate = options.sampleRate; this.channels = options.channels ?? 1; this.bitsPerSample = options.bitsPerSample ?? 16; } /** Open the file and write a placeholder 44-byte WAV header. */ open(): void { if (this.fd !== null) throw new Error('WavWriter already open'); this.fd = fs.openSync(this.filePath, 'w'); this.totalDataBytes = 0; this.closed = false; // Write 44 bytes of zeros as placeholder — patched in close(). const placeholder = Buffer.alloc(HEADER_SIZE); fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0); } /** Append raw 16-bit little-endian PCM samples. */ write(pcm: Buffer): void { if (this.fd === null || this.closed) return; if (pcm.length === 0) return; fs.writeSync(this.fd, pcm, 0, pcm.length); this.totalDataBytes += pcm.length; } /** * Finalize: rewrite the RIFF and data chunk sizes in the header, close the file. * Returns metadata about the written WAV. */ close(): IWavWriterResult { if (this.fd === null || this.closed) { return { filePath: this.filePath, durationMs: 0, sampleRate: this.sampleRate, totalSamples: 0, fileSize: HEADER_SIZE, }; } this.closed = true; const blockAlign = this.channels * (this.bitsPerSample / 8); const byteRate = this.sampleRate * blockAlign; const fileSize = HEADER_SIZE + this.totalDataBytes; // Build the complete 44-byte header. const hdr = Buffer.alloc(HEADER_SIZE); let offset = 0; // RIFF chunk descriptor. hdr.write('RIFF', offset); offset += 4; hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8 hdr.write('WAVE', offset); offset += 4; // fmt sub-chunk. hdr.write('fmt ', offset); offset += 4; hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16) hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM) hdr.writeUInt16LE(this.channels, offset); offset += 2; hdr.writeUInt32LE(this.sampleRate, offset); offset += 4; hdr.writeUInt32LE(byteRate, offset); offset += 4; hdr.writeUInt16LE(blockAlign, offset); offset += 2; hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2; // data sub-chunk. hdr.write('data', offset); offset += 4; hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4; // Patch the header at the beginning of the file. fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0); fs.closeSync(this.fd); this.fd = null; const bytesPerSample = this.bitsPerSample / 8; const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels)); const durationMs = (totalSamples / this.sampleRate) * 1000; return { filePath: this.filePath, durationMs: Math.round(durationMs), sampleRate: this.sampleRate, totalSamples, fileSize, }; } /** Current recording duration in milliseconds. */ get durationMs(): number { const bytesPerSample = this.bitsPerSample / 8; const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels)); return (totalSamples / this.sampleRate) * 1000; } /** Whether the writer is still open and accepting data. */ get isOpen(): boolean { return this.fd !== null && !this.closed; } }