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:
@@ -8,6 +8,7 @@ import { runEventScheduleSmoke } from "./browser_event_schedule.js";
|
||||
import { runSettingsTabsSmoke } from "./browser_settings_tabs.js";
|
||||
import { runGuideTabsSmoke } from "./browser_guide_tabs.js";
|
||||
import { runAuthSmoke } from "./browser_auth.js";
|
||||
import { runTtsSettingsSmoke } from "./browser_tts_settings.js";
|
||||
import { runObsPopupSmoke } from "./browser_obs_popup.js";
|
||||
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -146,6 +147,7 @@ try {
|
||||
await runSettingsTabsSmoke({ evaluate, navigate });
|
||||
await runGuideTabsSmoke({ evaluate, navigate });
|
||||
await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor });
|
||||
await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor });
|
||||
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
|
||||
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
|
||||
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
|
||||
|
||||
29
tests/browser_tts_settings.js
Normal file
29
tests/browser_tts_settings.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// The voice engine panel in Settings -> Audio: renders its Swedish/English voice status,
|
||||
// and the engine choice persists to the backend and survives a reload. Whether Piper is
|
||||
// actually installed on the test machine doesn't matter here - generation and fallback
|
||||
// are covered by tests/tts.test.js, tests/piper_speech.test.js and the integration test.
|
||||
export async function runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor }) {
|
||||
const openAudioTab = async () => {
|
||||
await navigate("settings", "settingsForm");
|
||||
await evaluate(`document.querySelector('[data-settings-tab="audio"]').click()`);
|
||||
await waitFor(async () => Boolean(await evaluate(`document.getElementById("ttsEngine")`)), "voice engine panel rendered");
|
||||
};
|
||||
|
||||
await openAudioTab();
|
||||
const panelText = await evaluate(`document.getElementById("ttsSettings").textContent`);
|
||||
assert.match(panelText, /Svenska/);
|
||||
assert.match(panelText, /English/);
|
||||
assert.equal(await evaluate(`document.getElementById("ttsEngine").value`), "browser", "the browser voice stays the default");
|
||||
|
||||
await evaluate(`(() => { const select = document.getElementById("ttsEngine"); select.value = "piper"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await savedState()).settings.ttsEngine === "piper", "engine choice saved to the backend");
|
||||
|
||||
await openAudioTab();
|
||||
assert.equal(await evaluate(`document.getElementById("ttsEngine").value`), "piper", "the choice survives a reload");
|
||||
console.log("PASS the voice engine panel shows Swedish/English status and the Piper choice persists across a reload");
|
||||
|
||||
await evaluate(`(() => { const select = document.getElementById("ttsEngine"); select.value = "browser"; select.dispatchEvent(new Event("change")); })()`);
|
||||
await waitFor(async () => (await savedState()).settings.ttsEngine === "browser", "engine reset for the rest of the suite");
|
||||
}
|
||||
81
tests/piper_speech.test.js
Normal file
81
tests/piper_speech.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import test, { mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Minimal browser stand-ins: Audio plays instantly and reports onended.
|
||||
class FakeAudio {
|
||||
constructor(url) { this.url = url; FakeAudio.last = this; }
|
||||
play() { setTimeout(() => this.onended?.(), 0); return Promise.resolve(); }
|
||||
pause() { this.paused = true; }
|
||||
}
|
||||
// run.js loads every test file into one process, so the real globals must come back
|
||||
// after each test here - the integration tests later use the real fetch.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAudio = globalThis.Audio;
|
||||
function useStubs(fetchStub) {
|
||||
globalThis.Audio = FakeAudio;
|
||||
globalThis.fetch = fetchStub;
|
||||
}
|
||||
function restoreStubs() {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.Audio = originalAudio;
|
||||
}
|
||||
|
||||
const { speakWithPiper, isPiperSpeaking, stopPiperSpeech } = await import("../src/piper_speech.js");
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
test("when Piper is unavailable the browser voice takes over", async () => {
|
||||
useStubs(async () => new Response("{}", { status: 503 }));
|
||||
try {
|
||||
let fallbackCalls = 0;
|
||||
assert.equal(speakWithPiper("Hej", { language: "sv", fallback: () => { fallbackCalls += 1; return true; } }), true);
|
||||
await flush();
|
||||
assert.equal(fallbackCalls, 1);
|
||||
assert.equal(isPiperSpeaking(), false);
|
||||
} finally { restoreStubs(); }
|
||||
});
|
||||
|
||||
test("a slow generation is abandoned after the timeout and falls back", async () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
try {
|
||||
useStubs((_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))));
|
||||
let fellBack = false;
|
||||
speakWithPiper("Hej", { language: "sv", fallback: () => { fellBack = true; return true; } });
|
||||
mock.timers.tick(2499);
|
||||
await Promise.resolve();
|
||||
assert.equal(fellBack, false, "still waiting just before the timeout");
|
||||
mock.timers.tick(1);
|
||||
for (let i = 0; i < 5; i += 1) await Promise.resolve();
|
||||
assert.equal(fellBack, true);
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
stopPiperSpeech();
|
||||
restoreStubs();
|
||||
}
|
||||
});
|
||||
|
||||
test("a generated line plays and reports onEnd, and a non-interrupting line is refused while busy", async () => {
|
||||
useStubs(async () => new Response(new Blob(["RIFF"], { type: "audio/wav" }), { status: 200 }));
|
||||
try {
|
||||
let ended = false;
|
||||
assert.equal(speakWithPiper("Heat ett", { language: "sv", volume: 0.4, onEnd: () => { ended = true; }, fallback: () => true }), true);
|
||||
assert.equal(speakWithPiper("Queued", { language: "sv", fallback: () => true }), false, "busy and not interrupting");
|
||||
await flush();
|
||||
assert.equal(FakeAudio.last.volume, 0.4);
|
||||
assert.equal(ended, true);
|
||||
assert.equal(isPiperSpeaking(), false);
|
||||
} finally { restoreStubs(); }
|
||||
});
|
||||
|
||||
test("an interrupting line stops the one in progress", async () => {
|
||||
useStubs(() => new Promise(() => {}));
|
||||
try {
|
||||
let firstFellBack = false;
|
||||
speakWithPiper("First", { language: "sv", fallback: () => { firstFellBack = true; return true; } });
|
||||
assert.equal(speakWithPiper("Second", { language: "sv", interrupt: true, fallback: () => true }), true);
|
||||
await flush();
|
||||
assert.equal(firstFellBack, false, "a cancelled line must not fall back and talk over the new one");
|
||||
} finally {
|
||||
stopPiperSpeech();
|
||||
restoreStubs();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import "./auth.test.js";
|
||||
import "./tts.test.js";
|
||||
import "./piper_speech.test.js";
|
||||
import "./driver_profile_logic.test.js";
|
||||
import "./training_logic.test.js";
|
||||
import "./public_training_profile.test.js";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
104
tests/tts.test.js
Normal file
104
tests/tts.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createTts, listVoices, pickVoice, resolvePiperBinary } = require("../server/tts.js");
|
||||
|
||||
// Stand-in for execFile: records each call and writes the requested output file, so the
|
||||
// module's queueing/caching can be tested without a real Piper install.
|
||||
function fakeRun(calls, { fail = false, delayMs = 5 } = {}) {
|
||||
let active = 0;
|
||||
return (binary, args, _options, callback) => {
|
||||
active += 1;
|
||||
calls.push({ binary, args, maxParallel: active });
|
||||
let text = "";
|
||||
const stdin = { end: (value) => { text = value; } };
|
||||
setTimeout(() => {
|
||||
active -= 1;
|
||||
if (fail) return callback(new Error("piper crashed"));
|
||||
writeFileSync(args[args.indexOf("-f") + 1], `RIFF${text}`);
|
||||
return callback(null);
|
||||
}, delayMs);
|
||||
return { stdin };
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), "live-rc-tts-"));
|
||||
const voices = join(root, "voices");
|
||||
mkdirSync(voices);
|
||||
for (const name of ["sv_SE-nst-medium", "sv_SE-lisa-low", "en_US-lessac-medium", "de_DE-thorsten-medium"]) {
|
||||
writeFileSync(join(voices, `${name}.onnx`), "");
|
||||
writeFileSync(join(voices, `${name}.onnx.json`), "{}");
|
||||
}
|
||||
writeFileSync(join(voices, "orphan-medium.onnx"), ""); // no .json config -> not a usable voice
|
||||
mkdirSync(join(root, "piper"));
|
||||
const binary = join(root, "piper", process.platform === "win32" ? "piper.exe" : "piper");
|
||||
writeFileSync(binary, "");
|
||||
chmodSync(binary, 0o755);
|
||||
return { root, voices, binary, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test("voices need a matching .onnx.json and are picked per language, preferring medium quality", () => {
|
||||
const env = setup();
|
||||
try {
|
||||
const voices = listVoices([env.voices, join(env.root, "missing-dir")]);
|
||||
assert.deepEqual(voices.map((voice) => voice.name), ["de_DE-thorsten-medium", "en_US-lessac-medium", "sv_SE-lisa-low", "sv_SE-nst-medium"]);
|
||||
assert.equal(pickVoice(voices, "sv").name, "sv_SE-nst-medium");
|
||||
assert.equal(pickVoice(voices, "en-US").name, "en_US-lessac-medium");
|
||||
assert.equal(pickVoice(voices, "fi"), null);
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test("the Piper copy installed into the project is preferred over one on PATH", () => {
|
||||
const env = setup();
|
||||
try {
|
||||
assert.equal(resolvePiperBinary("", env.root), env.binary);
|
||||
assert.equal(resolvePiperBinary(env.binary, "/nonexistent"), env.binary, "an explicit path wins");
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test("each line is generated once, cached, deduplicated, and generated one at a time", async () => {
|
||||
const env = setup();
|
||||
const calls = [];
|
||||
try {
|
||||
const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls) });
|
||||
const [a, b] = await Promise.all([
|
||||
tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }),
|
||||
tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }),
|
||||
]);
|
||||
assert.equal(a, b, "whitespace-normalized duplicates share one file");
|
||||
assert.equal(calls.length, 1, "concurrent identical requests only run Piper once");
|
||||
await tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 });
|
||||
assert.equal(calls.length, 1, "a cached line never runs Piper again");
|
||||
|
||||
await Promise.all([tts.speech({ text: "One", language: "en" }), tts.speech({ text: "Two", language: "en" }), tts.speech({ text: "Tre", language: "sv", rate: 0.5 })]);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.ok(calls.every((call) => call.maxParallel === 1), "generations never overlap");
|
||||
assert.ok(calls[1].args.includes(join(env.voices, "en_US-lessac-medium.onnx")), "the English voice is used for English text");
|
||||
assert.equal(calls[3].args[calls[3].args.indexOf("--length_scale") + 1], "1.6", "slow speech rate maps to a longer (clamped) length scale");
|
||||
|
||||
const status = tts.status();
|
||||
assert.equal(status.available, true);
|
||||
assert.equal(status.cachedLines, 4);
|
||||
assert.equal(readdirSync(join(env.root, "data", "tts")).filter((file) => file.endsWith(".tmp")).length, 0, "no temp files are left behind");
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test("missing voice or failing Piper reports clear errors, and pre-generation counts failures", async () => {
|
||||
const env = setup();
|
||||
const calls = [];
|
||||
try {
|
||||
const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls, { fail: true }) });
|
||||
await assert.rejects(tts.speech({ text: "Moi", language: "fi" }), (error) => error.code === "unavailable");
|
||||
await assert.rejects(tts.speech({ text: " ", language: "sv" }), (error) => error.code === "bad_request");
|
||||
await assert.rejects(tts.speech({ text: "Hej", language: "sv" }), /piper crashed/);
|
||||
|
||||
const result = await tts.pregenerate([{ text: "Hej", language: "sv" }, { text: "Hi", language: "en" }, { text: "", language: "sv" }]);
|
||||
assert.deepEqual(result, { running: false, total: 2, done: 2, failed: 2 });
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
Reference in New Issue
Block a user