Files
Veskslop-t/poc/src/capture.ts
T

104 lines
2.9 KiB
TypeScript

/*
* Veskslop daemon — capture pipeline.
*
* Reads the client output monitor as raw PCM, splits it into utterances with
* the VAD, and writes one WAV per utterance. The caller stores the metadata.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pcmToWav, Vad, type VadConfig, type VadSegment } from "./vad.js";
export interface CaptureSegment {
file: string;
durationMs: number;
startedAt: string;
endedAt: string;
}
export interface CapturePipelineOptions {
ffmpeg: string;
target: string;
directory: string;
sessionId: string;
vad: VadConfig;
onSegment: (segment: CaptureSegment) => void;
onError: (error: Error) => void;
}
export class CapturePipeline {
private child?: ChildProcess;
private vad?: Vad;
private counter = 0;
private stopped = false;
constructor(private readonly options: CapturePipelineOptions) {}
start() {
mkdirSync(this.options.directory, { recursive: true });
this.vad = new Vad(this.options.vad, segment => this.emit(segment));
this.child = spawn(
this.options.ffmpeg,
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"pulse",
"-i",
this.options.target,
"-ac",
"1",
"-ar",
String(this.options.vad.sampleRate),
"-f",
"s16le",
"-"
],
{ stdio: ["ignore", "pipe", "pipe"] }
);
let stderr = "";
this.child.stdout?.on("data", chunk => this.vad?.push(chunk));
this.child.stderr?.on("data", chunk => (stderr += chunk));
this.child.on("error", error => this.options.onError(new Error(String(error))));
this.child.on("close", code => {
if (!this.stopped && code) {
this.options.onError(new Error(stderr.trim() || `ffmpeg exited with ${code}`));
}
});
}
stop() {
this.stopped = true;
this.vad?.flush();
this.vad = undefined;
try {
this.child?.kill("SIGTERM");
} catch {
// already gone
}
this.child = undefined;
}
private emit(segment: VadSegment) {
this.counter++;
const endedAt = new Date();
const startedAt = new Date(endedAt.getTime() - segment.durationMs);
const file = join(this.options.directory, `${this.options.sessionId}-${this.counter}.wav`);
writeFileSync(file, pcmToWav(segment.pcm, this.options.vad?.sampleRate ?? 16000));
this.options.onSegment({
file,
durationMs: segment.durationMs,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
}
}