feat(call, voicemail, ivr): add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
This commit is contained in:
163
ts/call/wav-writer.ts
Normal file
163
ts/call/wav-writer.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user