Files
siprouter/ts/call/wav-writer.ts

164 lines
5.2 KiB
TypeScript

/**
* 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;
}
}