Add voice audio, capture and transcript, browser monitor, and DM UI

This commit is contained in:
Fisto
2026-09-14 11:19:06 +02:00
parent 65f0788502
commit c003d9532f
27 changed files with 4479 additions and 239 deletions
+225
View File
@@ -0,0 +1,225 @@
/*
* Veskslop daemon — text to speech.
*
* Providers: a local engine (espeak-ng when installed, or VESKSLOP_TTS_CMD)
* and an HTTP API. The local command receives an output path ({out}), a text
* file ({text_file}), and optionally a voice ({voice}).
*/
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export class TtsError extends Error {}
export type TtsProvider = "local" | "api";
export interface TtsOptions {
provider?: TtsProvider;
voice?: string;
}
export interface TtsStatus {
local: boolean;
api: boolean;
defaultProvider: TtsProvider;
engine: string | null;
}
export interface TtsVoice {
id: string;
name: string;
language: string;
gender: "male" | "female" | null;
}
interface CommandResult {
code: number | null;
stdout: string;
stderr: string;
}
function run(command: string, args: string[]): Promise<CommandResult> {
return new Promise(resolve => {
const child = spawn(command, args, { 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 => resolve({ code: null, stdout, stderr: String(error) }));
child.on("close", code => resolve({ code, stdout, stderr }));
});
}
function parseVoices(output: string): TtsVoice[] {
const lines = output.split("\n");
const header = lines[0] ?? "";
const languageIndex = header.indexOf("Language");
const ageIndex = header.indexOf("Age/Gender");
const nameIndex = header.indexOf("VoiceName");
const fileIndex = header.indexOf("File");
if (languageIndex < 0 || ageIndex < 0 || nameIndex < 0 || fileIndex < 0) return [];
const voices: TtsVoice[] = [];
for (const line of lines.slice(1)) {
const language = line.slice(languageIndex, ageIndex).trim();
const ageGender = line.slice(ageIndex, nameIndex).trim();
const name = line.slice(nameIndex, fileIndex).trim();
if (!language || !name) continue;
voices.push({
id: language,
name,
language,
gender: ageGender.endsWith("F") ? "female" : ageGender.endsWith("M") ? "male" : null
});
}
return voices;
}
export class Tts {
private readonly dir: string;
private counter = 0;
private espeak = false;
private voiceCache?: TtsVoice[];
constructor(cacheDir?: string) {
const base = cacheDir ?? process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache");
this.dir = join(base, "audio");
try {
mkdirSync(this.dir, { recursive: true });
} catch {
// the fallback directory works
}
}
get localCommand(): string | undefined {
return process.env.VESKSLOP_TTS_CMD;
}
get apiUrl(): string | undefined {
return process.env.VESKSLOP_TTS_API_URL;
}
get directory(): string {
return this.dir;
}
async detect(): Promise<void> {
const result = await run("espeak-ng", ["--version"]);
this.espeak = result.code === 0;
}
status(): TtsStatus {
const local = !!this.localCommand || this.espeak;
const override = process.env.VESKSLOP_TTS_PROVIDER;
return {
local,
api: !!this.apiUrl,
defaultProvider: override === "local" || override === "api" ? override : local ? "local" : "api",
engine: this.localCommand ? "command" : this.espeak ? "espeak-ng" : null
};
}
async voices(): Promise<TtsVoice[]> {
if (this.voiceCache) return this.voiceCache;
if (!this.espeak) return (this.voiceCache = []);
const result = await run("espeak-ng", ["--voices"]);
this.voiceCache = result.code === 0 ? parseVoices(result.stdout) : [];
return this.voiceCache;
}
async synthesize(text: string, options: TtsOptions = {}): Promise<string> {
const provider = options.provider ?? this.status().defaultProvider;
return provider === "api" ? this.viaApi(text, options) : this.viaLocalCommand(text, options);
}
private async viaLocalCommand(text: string, options: TtsOptions): Promise<string> {
const command = this.localCommand;
if (!command && !this.espeak) {
throw new TtsError("no local TTS engine is available (install espeak-ng or set VESKSLOP_TTS_CMD)");
}
if (command && !command.includes("{out}")) {
throw new TtsError("VESKSLOP_TTS_CMD must contain {out}");
}
const out = this.nextPath("wav");
const textFile = this.nextPath("txt");
writeFileSync(textFile, text);
const parts = command
? command
.split(/\s+/)
.filter(Boolean)
.map(part =>
part
.replace("{out}", out)
.replace("{text_file}", textFile)
.replace("{voice}", options.voice ?? "")
)
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
const args = parts.filter(Boolean);
await new Promise<void>((resolve, reject) => {
const child = spawn(args[0], args.slice(1), {
env: { ...process.env, VESKSLOP_TTS_VOICE: options.voice ?? "" },
stdio: ["ignore", "ignore", "pipe"]
});
let stderr = "";
child.stderr?.on("data", chunk => (stderr += chunk));
child.on("error", error => reject(new TtsError(String(error))));
child.on("close", code => {
if (code === 0) resolve();
else reject(new TtsError(stderr.trim() || `the TTS command exited with ${code}`));
});
});
return out;
}
private async viaApi(text: string, options: TtsOptions): Promise<string> {
const url = this.apiUrl;
if (!url) throw new TtsError("VESKSLOP_TTS_API_URL is not set");
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.VESKSLOP_TTS_API_KEY
? { Authorization: `Bearer ${process.env.VESKSLOP_TTS_API_KEY}` }
: {})
},
body: JSON.stringify({ text, ...(options.voice ? { voice: options.voice } : {}) })
});
if (!response.ok) throw new TtsError(`the TTS API returned ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
const type = response.headers.get("content-type") ?? "";
const extension =
type.includes("mpeg") || type.includes("mp3")
? "mp3"
: type.includes("ogg")
? "ogg"
: type.includes("wav")
? "wav"
: "mp3";
const out = this.nextPath(extension);
writeFileSync(out, buffer);
return out;
}
private nextPath(extension: string): string {
this.counter++;
return join(this.dir, `tts-${Date.now()}-${this.counter}.${extension}`);
}
}