// Local neural text-to-speech via Piper (https://github.com/rhasspy/piper). Fully offline: // each spoken line is generated once, cached as a WAV under data/tts/, and replayed from // disk after that. Works with the Python `piper-tts` package (Linux/macOS/Windows) and the // standalone piper/piper.exe release - both accept -m, -f and --length_scale. const crypto = require("crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); const { execFile } = require("child_process"); const { VOICES: VOICE_CATALOG } = require("./tts_install"); const MAX_TEXT_LENGTH = 300; const GENERATE_TIMEOUT_MS = 20000; // Order: explicit RC_PIPER_BIN, then the copy `npm run tts:install` puts in /piper/ // (known-good standalone build), then PATH, then pipx's default bin dir - a background // service often runs without the user's full PATH. function resolvePiperBinary(configured, projectDir) { const names = process.platform === "win32" ? ["piper.exe", "piper.cmd", "piper"] : ["piper"]; const candidates = []; if (configured) candidates.push(configured); names.forEach((name) => candidates.push(path.join(projectDir, "piper", name))); String(process.env.PATH || "").split(path.delimiter).filter(Boolean).forEach((dir) => names.forEach((name) => candidates.push(path.join(dir, name)))); names.forEach((name) => candidates.push(path.join(os.homedir(), ".local", "bin", name))); return candidates.find((candidate) => { try { return fs.statSync(candidate).isFile(); } catch { return false; } }) || null; } // A voice is a model file plus its matching .onnx.json config, named _--. function listVoices(voiceDirs) { const voices = new Map(); voiceDirs.forEach((dir) => { let entries = []; try { entries = fs.readdirSync(dir); } catch { return; } entries.filter((entry) => entry.endsWith(".onnx") && entries.includes(`${entry}.json`)).forEach((entry) => { const name = entry.slice(0, -".onnx".length); const known = VOICE_CATALOG.find((voice) => voice.name === name); if (!voices.has(name)) voices.set(name, { name, file: path.join(dir, entry), language: name.split(/[_-]/)[0].toLowerCase(), gender: known?.gender || "", accent: known?.accent || "", label: known?.label || name, isDefault: Boolean(known?.default) }); }); }); return [...voices.values()].sort((a, b) => a.name.localeCompare(b.name)); } // The voice chosen in Settings if it's installed and speaks this language; otherwise the // catalog default for the language, then any medium-quality voice, then anything. function pickVoice(voices, language, preferredName = "") { const prefix = String(language || "").toLowerCase().split("-")[0]; const matching = voices.filter((voice) => voice.language === prefix); return matching.find((voice) => voice.name === preferredName) || matching.find((voice) => voice.isDefault) || matching.find((voice) => voice.name.endsWith("-medium")) || matching[0] || null; } function createTts({ dataDir, projectDir, piperBin = process.env.RC_PIPER_BIN || "", voiceDirs = [], run = execFile }) { const cacheDir = path.join(dataDir, "tts"); const inFlight = new Map(); let queue = Promise.resolve(); function status() { const binary = resolvePiperBinary(piperBin, projectDir); const voices = listVoices(voiceDirs); let cachedLines = 0; try { cachedLines = fs.readdirSync(cacheDir).filter((entry) => entry.endsWith(".wav")).length; } catch {} const installed = new Set(voices.map((voice) => voice.name)); // Catalog voices not installed yet, offered for download in Settings. const available = VOICE_CATALOG.filter((voice) => !installed.has(voice.name)) .map(({ name, language, gender, accent = "", label }) => ({ name, language, gender, accent, label })); return { available: Boolean(binary && voices.length), binary, voices: voices.map(({ name, language, gender, accent, label, isDefault }) => ({ name, language, gender, accent, label, isDefault })), downloadable: available, voiceDirs, cachedLines }; } // The voice name and speed are part of the key, so changing either never replays a stale line. function cachePathFor(voice, text, lengthScale) { const key = crypto.createHash("sha256").update(`${voice.name}\n${lengthScale}\n${text}`).digest("hex"); return path.join(cacheDir, `${key}.wav`); } // One Piper process at a time: loading a model is CPU-heavy, and a burst of parallel // generations must never starve the timing server itself. function generate(binary, voice, text, lengthScale, outPath) { const job = queue.then(() => new Promise((resolve, reject) => { fs.mkdirSync(cacheDir, { recursive: true }); const tmpPath = `${outPath}.${process.pid}.tmp`; const child = run(binary, ["-m", voice.file, "-f", tmpPath, "--length_scale", String(lengthScale)], { timeout: GENERATE_TIMEOUT_MS, windowsHide: true }, (error) => { if (error || !fs.existsSync(tmpPath)) { fs.rmSync(tmpPath, { force: true }); reject(error || new Error("Piper produced no audio")); return; } fs.renameSync(tmpPath, outPath); resolve(outPath); }); child.stdin?.end(`${text}\n`); })); queue = job.catch(() => {}); return job; } // Resolves to a cached WAV path, generating it first if needed. Throws with // code "unavailable" when Piper or a voice for the language is missing. async function speech({ text, language, rate, voice: preferredVoice }) { const cleanText = String(text || "").replace(/\s+/g, " ").trim().slice(0, MAX_TEXT_LENGTH); if (!cleanText) throw Object.assign(new Error("No text"), { code: "bad_request" }); const binary = resolvePiperBinary(piperBin, projectDir); const voice = pickVoice(listVoices(voiceDirs), language, preferredVoice); if (!binary || !voice) throw Object.assign(new Error("Piper or a voice for this language is not installed"), { code: "unavailable" }); // Piper's length scale is duration, the inverse of the browser's speaking rate. const lengthScale = Math.round(Math.max(0.6, Math.min(1.6, 1 / (Number(rate) || 1))) * 100) / 100; const outPath = cachePathFor(voice, cleanText, lengthScale); if (fs.existsSync(outPath)) return outPath; if (!inFlight.has(outPath)) { inFlight.set(outPath, generate(binary, voice, cleanText, lengthScale, outPath).finally(() => inFlight.delete(outPath))); } return inFlight.get(outPath); } const progress = { running: false, total: 0, done: 0, failed: 0 }; async function pregenerate(items) { if (progress.running) return progress; const list = (Array.isArray(items) ? items : []).filter((item) => String(item?.text || "").trim()).slice(0, 2000); Object.assign(progress, { running: true, total: list.length, done: 0, failed: 0 }); for (const item of list) { try { await speech(item); } catch { progress.failed += 1; } progress.done += 1; } progress.running = false; return progress; } return { status: () => ({ ...status(), pregenerate: { ...progress } }), speech, pregenerate }; } module.exports = { createTts, listVoices, pickVoice, resolvePiperBinary, MAX_TEXT_LENGTH };