136 lines
4.4 KiB
TypeScript
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();
|
|
}
|
|
}
|