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

136 lines
4.4 KiB
TypeScript

/*
* 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<void> {
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<string | undefined> {
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<string, string> = {}
): Promise<string> {
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<string>((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<string> {
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();
}
}