feat(audio): add offline local neural voice via Piper (sv + en)
Announcements can use Piper, a free local neural TTS, instead of the browser's system voice. No account, no paid service, and it keeps working with no internet at the track. - npm run tts:install (or Settings -> Audio -> Install) downloads the standalone Piper release for Linux/Windows/macOS plus Swedish and English voices into piper/ once - server/tts.js generates each line once, one at a time, and caches it as WAV under data/tts/; a new line takes ~0.3 s with the standalone build - src/piper_speech.js plays it and falls back to the browser voice if Piper is missing, errors, or takes over 2.5 s, so no announcement is lost and timing is never held up - "Pre-generate phrases and driver names" renders every fixed phrase and name in both languages before race day Browser voice stays the default. Generation routes fall under the admin PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
123
server/tts.js
Normal file
123
server/tts.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// 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 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);
|
||||
if (!voices.has(name)) voices.set(name, { name, file: path.join(dir, entry), language: name.split(/[_-]/)[0].toLowerCase() });
|
||||
});
|
||||
});
|
||||
return [...voices.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function pickVoice(voices, language) {
|
||||
const prefix = String(language || "").toLowerCase().split("-")[0];
|
||||
const matching = voices.filter((voice) => voice.language === prefix);
|
||||
return 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 }) => ({ name, language })), 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 }) {
|
||||
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);
|
||||
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 };
|
||||
65
server/tts_install.js
Normal file
65
server/tts_install.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Downloads the standalone Piper release for this OS and the Swedish + English voices
|
||||
// into <project>/piper/. Needs internet once; everything runs offline afterwards.
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { Readable } = require("stream");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const PIPER_RELEASE_BASE = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2";
|
||||
const PIPER_ARCHIVES = {
|
||||
"linux-x64": "piper_linux_x86_64.tar.gz",
|
||||
"linux-arm64": "piper_linux_aarch64.tar.gz",
|
||||
"win32-x64": "piper_windows_amd64.zip",
|
||||
"darwin-x64": "piper_macos_x64.tar.gz",
|
||||
"darwin-arm64": "piper_macos_aarch64.tar.gz",
|
||||
};
|
||||
const VOICE_BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/main";
|
||||
const VOICES = [
|
||||
{ name: "sv_SE-nst-medium", path: "sv/sv_SE/nst/medium" },
|
||||
{ name: "en_US-lessac-medium", path: "en/en_US/lessac/medium" },
|
||||
];
|
||||
|
||||
async function download(url, target) {
|
||||
const response = await fetch(url, { redirect: "follow" });
|
||||
if (!response.ok || !response.body) throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
const partial = `${target}.part`;
|
||||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(partial));
|
||||
fs.renameSync(partial, target);
|
||||
}
|
||||
|
||||
async function installPiperBinary({ projectDir, log }) {
|
||||
const archive = PIPER_ARCHIVES[`${process.platform}-${process.arch}`];
|
||||
if (!archive) throw new Error(`No standalone Piper release for ${process.platform}-${process.arch}`);
|
||||
const tmpFile = path.join(os.tmpdir(), `racecore-${archive}`);
|
||||
log(`Downloading Piper (${archive})...`);
|
||||
await download(`${PIPER_RELEASE_BASE}/${archive}`, tmpFile);
|
||||
log("Unpacking Piper...");
|
||||
// tar ships with Linux, macOS and Windows 10+, and the Windows one also unpacks .zip.
|
||||
const result = spawnSync("tar", ["-xf", tmpFile, "-C", projectDir], { encoding: "utf8", windowsHide: true });
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
if (result.status !== 0) throw new Error(`Unpacking failed: ${result.stderr || result.error?.message || "tar error"}`);
|
||||
}
|
||||
|
||||
async function installVoices({ voicesDir, log }) {
|
||||
fs.mkdirSync(voicesDir, { recursive: true });
|
||||
for (const voice of VOICES) {
|
||||
for (const file of [`${voice.name}.onnx.json`, `${voice.name}.onnx`]) {
|
||||
const target = path.join(voicesDir, file);
|
||||
if (fs.existsSync(target)) continue;
|
||||
log(`Downloading voice ${file}...`);
|
||||
await download(`${VOICE_BASE}/${voice.path}/${file}`, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skips anything already present, so re-running is cheap and safe.
|
||||
async function installAll({ projectDir, hasBinary, log = () => {} }) {
|
||||
if (!hasBinary) await installPiperBinary({ projectDir, log });
|
||||
else log("Piper already installed.");
|
||||
await installVoices({ voicesDir: path.join(projectDir, "piper", "voices"), log });
|
||||
log("Done.");
|
||||
}
|
||||
|
||||
module.exports = { installAll, installVoices, installPiperBinary, VOICES, PIPER_ARCHIVES };
|
||||
Reference in New Issue
Block a user