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

View File

@@ -572,3 +572,59 @@ test("an admin PIN blocks unauthenticated writes, and the login cookie lets them
rmSync(dataDir, { recursive: true, force: true });
}
});
test("local Piper speech is served as WAV, cached, and falls under the admin PIN", { timeout: 20000, skip: process.platform === "win32" }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-tts-"));
const voicesDir = join(dataDir, "voices");
fs.mkdirSync(voicesDir);
for (const name of ["sv_SE-test-medium", "en_US-test-medium"]) {
fs.writeFileSync(join(voicesDir, `${name}.onnx`), "");
fs.writeFileSync(join(voicesDir, `${name}.onnx.json`), "{}");
}
// Fake Piper: counts invocations and writes the text it was given into the output file.
const fakePiper = join(dataDir, "fake-piper");
const counter = join(dataDir, "piper-calls");
fs.writeFileSync(fakePiper, `#!/usr/bin/env node
const fs = require("fs");
const out = process.argv[process.argv.indexOf("-f") + 1];
let text = "";
process.stdin.on("data", (chunk) => { text += chunk; });
process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x"); fs.writeFileSync(out, "RIFF" + text); });
`);
fs.chmodSync(fakePiper, 0o755);
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [join(projectRoot, "server.js")], {
cwd: projectRoot,
env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_PIPER_BIN: fakePiper, RC_PIPER_VOICES_DIR: voicesDir },
stdio: ["ignore", "pipe", "pipe"],
});
const speech = (body, cookie = "") => fetch(`${baseUrl}/api/tts/speech`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) });
try {
await waitForServer(child);
const status = await (await fetch(`${baseUrl}/api/tts/status`)).json();
assert.equal(status.available, true);
assert.equal(status.binary, fakePiper);
assert.ok(["sv", "en"].every((language) => status.voices.some((voice) => voice.language === language)), "both Swedish and English voices are found");
const first = await speech({ text: "Heat ett startar.", language: "sv", rate: 1 });
assert.equal(first.status, 200);
assert.match(first.headers.get("content-type"), /audio\/wav/);
assert.equal(await first.text(), "RIFFHeat ett startar.\n");
assert.equal((await speech({ text: "Heat ett startar.", language: "sv", rate: 1 })).status, 200);
assert.equal(fs.readFileSync(counter, "utf8").length, 1, "the second request is served from the cache");
assert.equal((await speech({ text: "", language: "sv" })).status, 400);
assert.equal((await speech({ text: "Moi", language: "fi" })).status, 503, "no voice for the language -> the browser falls back");
const setPin = await fetch(`${baseUrl}/api/auth/pin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ newPin: "4711" }) });
const cookie = setPin.headers.get("set-cookie").split(";")[0];
assert.equal((await speech({ text: "Hej", language: "sv" })).status, 401, "generating audio needs the PIN once protection is on");
assert.equal((await speech({ text: "Hej", language: "sv" }, cookie)).status, 200);
} finally {
await stopServer(child);
rmSync(dataDir, { recursive: true, force: true });
}
});