/** * TenVADWorkerClient * * Main-thread client for the TEN-VAD Web Worker. * Provides init/dispose lifecycle and fire-and-forget audio processing. * Results are delivered via a callback rather than promises, since * processing is streaming (one result per audio chunk, not request/response). */ import type { TenVADConfig, TenVADResponse, TenVADResult } from '../buffer/types'; export type TenVADResultCallback = (result: TenVADResult) => void; export class TenVADWorkerClient { private worker: Worker; private messageId = 0; private pendingPromises = new Map void; reject: (e: any) => void }>(); private resultCallback: TenVADResultCallback | null = null; private ready = false; constructor() { this.worker = new Worker(new URL('./tenvad.worker.ts', import.meta.url), { type: 'module', }); this.worker.onmessage = (e: MessageEvent) => { this.handleMessage(e.data); }; this.worker.onerror = (e: Event) => { const err = e as ErrorEvent; console.error('[TenVADWorkerClient] Worker error:', err.message); for (const [, p] of this.pendingPromises) { p.reject(new Error(err.message || 'TenVAD worker error')); } this.pendingPromises.clear(); }; } /** * Initialize the TEN-VAD WASM module. * @param config - hop size, threshold, WASM path */ async init(config: Partial = {}): Promise<{ version?: string }> { const cfg: TenVADConfig = { hopSize: config.hopSize ?? 256, threshold: config.threshold ?? 0.5, wasmPath: config.wasmPath ?? '/wasm/', }; const result = await this.sendRequest('INIT', cfg); this.ready = true; return result; } /** * Register a callback for streaming VAD results. * Called once per audio chunk processed. */ onResult(callback: TenVADResultCallback): void { this.resultCallback = callback; } /** * Send an audio chunk for processing (fire-and-forget). * Results are delivered via the onResult callback. * * @param samples - Float32 PCM audio at 16kHz * @param globalSampleOffset - Global sample index of the chunk start */ process(samples: Float32Array, globalSampleOffset: number): void { if (!this.ready) return; const copy = new Float32Array(samples); this.worker.postMessage( { type: 'PROCESS', payload: { samples: copy, globalSampleOffset } }, [copy.buffer] ); } /** * Send an audio chunk by transferring ownership of the buffer. * The caller must not reuse `samples` after calling this. */ processTransfer(samples: Float32Array, globalSampleOffset: number): void { if (!this.ready) return; this.worker.postMessage( { type: 'PROCESS', payload: { samples, globalSampleOffset } }, [samples.buffer] ); } /** * Reset internal state (accumulator, VAD model state). */ async reset(): Promise { await this.sendRequest('RESET', undefined); } /** * Dispose the worker and free WASM memory. */ dispose(): void { this.worker.terminate(); for (const [, p] of this.pendingPromises) { p.reject(new Error('TenVADWorkerClient disposed')); } this.pendingPromises.clear(); this.ready = false; this.resultCallback = null; } isReady(): boolean { return this.ready; } // ---- Internal ---- private handleMessage(msg: TenVADResponse): void { // Streaming results don't have an id if (msg.type === 'RESULT') { this.resultCallback?.(msg.payload); return; } if (msg.type === 'ERROR') { const p = this.pendingPromises.get(msg.id); if (p) { this.pendingPromises.delete(msg.id); p.reject(new Error(msg.payload)); } else { console.error('[TenVADWorkerClient] Unhandled error:', msg.payload); } return; } if (msg.id !== undefined) { const p = this.pendingPromises.get(msg.id); if (p) { this.pendingPromises.delete(msg.id); p.resolve(msg.payload); } } } private sendRequest(type: string, payload: any): Promise { return new Promise((resolve, reject) => { const id = ++this.messageId; this.pendingPromises.set(id, { resolve, reject }); this.worker.postMessage({ type, payload, id }); }); } }