The installer now fetches one male and one female Piper voice per language (Swedish NST/Lisa, English Ryan/Lessac), with genders confirmed by measuring each voice's pitch rather than guessed from the name. Settings -> Audio gets a voice selector per language with its own Test button that always plays the Piper voice. Male is preselected for both. The integration test now runs its server with an empty home directory, so voices installed on the developer's own machine can't leak into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
6.7 KiB
JavaScript
132 lines
6.7 KiB
JavaScript
// 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 <project>/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 <lang>_<REGION>-<name>-<quality>.
|
|
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 || "", 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 {}
|
|
return { available: Boolean(binary && voices.length), binary, voices: voices.map(({ name, language, gender, label, isDefault }) => ({ name, language, gender, label, isDefault })), 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 };
|