Add voice audio, capture and transcript, browser monitor, and DM UI
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user