1import * as fs from 'node:fs'
3// Incremental WAV (PCM s16le) writer. Audio arrives as 10 ms Int16 frames
4// from an RTCAudioSink; we buffer ~1 s in memory, then append to disk and
5// re-patch the RIFF/data sizes in the header, so a crash loses at most the
6// last second and still leaves a playable file.
8const HEADER_BYTES = 44
10export class WavWriter {
11 private fd: number
12 private buffered: Buffer[] = []
13 private bufferedBytes = 0
14 private dataBytes = 0
15 private finalized = false
17 /** Total frames (samples per channel) written or buffered so far. */
18 framesWritten = 0
20 constructor(
21 readonly path: string,
22 readonly sampleRate: number,
23 readonly channels: number
24 ) {
25 this.fd = fs.openSync(path, 'w')
26 fs.writeSync(this.fd, this.header())
27 }
29 private get flushThreshold(): number {
30 return this.sampleRate * this.channels * 2 // one second of PCM
31 }
33 private header(): Buffer {
34 const h = Buffer.alloc(HEADER_BYTES)
35 h.write('RIFF', 0, 'ascii')
36 h.writeUInt32LE(36 + this.dataBytes, 4)
37 h.write('WAVE', 8, 'ascii')
38 h.write('fmt ', 12, 'ascii')
39 h.writeUInt32LE(16, 16) // fmt chunk size
40 h.writeUInt16LE(1, 20) // PCM
41 h.writeUInt16LE(this.channels, 22)
42 h.writeUInt32LE(this.sampleRate, 24)
43 h.writeUInt32LE(this.sampleRate * this.channels * 2, 28) // byte rate
44 h.writeUInt16LE(this.channels * 2, 32) // block align
45 h.writeUInt16LE(16, 34) // bits per sample
46 h.write('data', 36, 'ascii')
47 h.writeUInt32LE(this.dataBytes, 40)
48 return h
49 }
51 /** Append interleaved s16 samples (copied — the sink reuses its buffer). */
52 append(samples: Int16Array) {
53 if (this.finalized) return
54 this.buffered.push(Buffer.copyBytesFrom(samples))
55 this.bufferedBytes += samples.byteLength
56 this.framesWritten += samples.length / this.channels
57 if (this.bufferedBytes >= this.flushThreshold) this.flush()
58 }
60 /** Append silent frames (frames = samples per channel). */
61 appendSilence(frames: number) {
62 if (this.finalized || frames <= 0) return
63 this.buffered.push(Buffer.alloc(frames * this.channels * 2))
64 this.bufferedBytes += frames * this.channels * 2
65 this.framesWritten += frames
66 if (this.bufferedBytes >= this.flushThreshold) this.flush()
67 }
69 get durationSec(): number {
70 return this.framesWritten / this.sampleRate
71 }
73 private flush() {
74 if (this.buffered.length === 0) return
75 const chunk = Buffer.concat(this.buffered.splice(0))
76 this.bufferedBytes = 0
77 fs.writeSync(this.fd, chunk, 0, chunk.length, HEADER_BYTES + this.dataBytes)
78 this.dataBytes += chunk.length
79 fs.writeSync(this.fd, this.header(), 0, HEADER_BYTES, 0)
80 }
82 finalize() {
83 if (this.finalized) return
84 this.flush()
85 this.finalized = true
86 fs.closeSync(this.fd)
87 }
88}