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:
larssand
2026-09-27 19:28:15 +02:00
parent 9016ce02e4
commit e8a2799a92
25 changed files with 776 additions and 19 deletions

65
server/tts_install.js Normal file
View 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 };