104 lines
2.9 KiB
TypeScript
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()
|
|
});
|
|
}
|
|
}
|