164 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
}
|