import * as fs from 'node:fs' // Incremental WAV (PCM s16le) writer. Audio arrives as 10 ms Int16 frames // from an RTCAudioSink; we buffer ~1 s in memory, then append to disk and // re-patch the RIFF/data sizes in the header, so a crash loses at most the // last second and still leaves a playable file. const HEADER_BYTES = 44 export class WavWriter { private fd: number private buffered: Buffer[] = [] private bufferedBytes = 0 private dataBytes = 0 private finalized = false /** Total frames (samples per channel) written or buffered so far. */ framesWritten = 0 constructor( readonly path: string, readonly sampleRate: number, readonly channels: number ) { this.fd = fs.openSync(path, 'w') fs.writeSync(this.fd, this.header()) } private get flushThreshold(): number { return this.sampleRate * this.channels * 2 // one second of PCM } private header(): Buffer { const h = Buffer.alloc(HEADER_BYTES) h.write('RIFF', 0, 'ascii') h.writeUInt32LE(36 + this.dataBytes, 4) h.write('WAVE', 8, 'ascii') h.write('fmt ', 12, 'ascii') h.writeUInt32LE(16, 16) // fmt chunk size h.writeUInt16LE(1, 20) // PCM h.writeUInt16LE(this.channels, 22) h.writeUInt32LE(this.sampleRate, 24) h.writeUInt32LE(this.sampleRate * this.channels * 2, 28) // byte rate h.writeUInt16LE(this.channels * 2, 32) // block align h.writeUInt16LE(16, 34) // bits per sample h.write('data', 36, 'ascii') h.writeUInt32LE(this.dataBytes, 40) return h } /** Append interleaved s16 samples (copied — the sink reuses its buffer). */ append(samples: Int16Array) { if (this.finalized) return this.buffered.push(Buffer.copyBytesFrom(samples)) this.bufferedBytes += samples.byteLength this.framesWritten += samples.length / this.channels if (this.bufferedBytes >= this.flushThreshold) this.flush() } /** Append silent frames (frames = samples per channel). */ appendSilence(frames: number) { if (this.finalized || frames <= 0) return this.buffered.push(Buffer.alloc(frames * this.channels * 2)) this.bufferedBytes += frames * this.channels * 2 this.framesWritten += frames if (this.bufferedBytes >= this.flushThreshold) this.flush() } get durationSec(): number { return this.framesWritten / this.sampleRate } private flush() { if (this.buffered.length === 0) return const chunk = Buffer.concat(this.buffered.splice(0)) this.bufferedBytes = 0 fs.writeSync(this.fd, chunk, 0, chunk.length, HEADER_BYTES + this.dataBytes) this.dataBytes += chunk.length fs.writeSync(this.fd, this.header(), 0, HEADER_BYTES, 0) } finalize() { if (this.finalized) return this.flush() this.finalized = true fs.closeSync(this.fd) } }