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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user