/* * Veskslop daemon — speech to text. * * Providers, in order: VESKSLOP_STT_CMD, a local Vosk model * (VESKSLOP_VOSK_MODEL or a `vosk-model*` folder under poc/models), and an * HTTP API. The local command receives the WAV path ({wav}) and prints text. */ import { spawn } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; export class SttError extends Error {} export interface SttStatus { local: boolean; api: boolean; engine: string | null; hint?: string; } export class Stt { private voskModel?: string; get localCommand(): string | undefined { return process.env.VESKSLOP_STT_CMD; } get apiUrl(): string | undefined { return process.env.VESKSLOP_STT_API_URL; } async detect(): Promise { this.voskModel = this.findVoskModel(); } status(): SttStatus { const local = !!(this.localCommand || this.voskModel); return { local, api: !!this.apiUrl, engine: this.localCommand ? "command" : this.voskModel ? "vosk" : null, hint: local || this.apiUrl ? undefined : "Install vosk (pip install vosk) and set VESKSLOP_VOSK_MODEL, or set VESKSLOP_STT_CMD." }; } /** Returns undefined when no provider is configured. */ async transcribe(wavPath: string): Promise { if (this.localCommand) return this.viaLocalCommand(wavPath, this.localCommand); if (this.voskModel) { const script = fileURLToPath(new URL("../scripts/stt-vosk.py", import.meta.url)); return this.viaLocalCommand(wavPath, `python3 ${script} {wav}`, { VESKSLOP_VOSK_MODEL: this.voskModel }); } if (this.apiUrl) return this.viaApi(wavPath); return undefined; } private findVoskModel(): string | undefined { const explicit = process.env.VESKSLOP_VOSK_MODEL; if (explicit) return explicit; const base = process.env.VESKSLOP_VOSK_DIR ?? fileURLToPath(new URL("../models", import.meta.url)); try { const model = readdirSync(base, { withFileTypes: true }).find( entry => entry.isDirectory() && entry.name.startsWith("vosk-model") ); return model ? join(base, model.name) : undefined; } catch { return undefined; } } private async viaLocalCommand( wavPath: string, command: string, extraEnv: Record = {} ): Promise { if (!command.includes("{wav}")) throw new SttError("the STT command must contain {wav}"); const parts = command .split(/\s+/) .filter(Boolean) .map(part => part.replace("{wav}", wavPath)); return new Promise((resolve, reject) => { const child = spawn(parts[0], parts.slice(1), { env: { ...process.env, ...extraEnv }, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout?.on("data", chunk => (stdout += chunk)); child.stderr?.on("data", chunk => (stderr += chunk)); child.on("error", error => reject(new SttError(String(error)))); child.on("close", code => { if (code === 0) resolve(stdout.trim()); else reject(new SttError(stderr.trim() || `the STT command exited with ${code}`)); }); }); } private async viaApi(wavPath: string): Promise { const response = await fetch(this.apiUrl!, { method: "POST", headers: { "Content-Type": "audio/wav", ...(process.env.VESKSLOP_STT_API_KEY ? { Authorization: `Bearer ${process.env.VESKSLOP_STT_API_KEY}` } : {}) }, body: readFileSync(wavPath) }); if (!response.ok) throw new SttError(`the STT API returned ${response.status}`); const type = response.headers.get("content-type") ?? ""; if (type.includes("json")) { const data: any = await response.json(); return String(data.text ?? data.transcription ?? "").trim(); } return (await response.text()).trim(); } }