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