From e8a2799a92b65705750bef969d44f0b0365fe4d8 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 27 Sep 2026 19:28:15 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + CHANGELOG.md | 6 + README.md | 10 ++ README.sv.md | 10 ++ ROADMAP.md | 17 ++- docs/javascript-architecture.md | 24 +++- docs/security-performance-audit.md | 8 ++ package-lock.json | 4 +- package.json | 3 +- scripts/install-piper.js | 13 ++ server.js | 39 ++++++ server/tts.js | 123 +++++++++++++++++++ server/tts_install.js | 65 ++++++++++ src/app.js | 34 ++++- src/misc_views.js | 2 +- src/piper_speech.js | 60 +++++++++ src/runtime_services.js | 17 ++- src/settings.js | 6 + src/tts_settings_view.js | 79 ++++++++++++ tests/browser_smoke.js | 2 + tests/browser_tts_settings.js | 29 +++++ tests/piper_speech.test.js | 81 ++++++++++++ tests/run.js | 2 + tests/server_persistence.integration.test.js | 56 +++++++++ tests/tts.test.js | 104 ++++++++++++++++ 25 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 scripts/install-piper.js create mode 100644 server/tts.js create mode 100644 server/tts_install.js create mode 100644 src/piper_speech.js create mode 100644 src/tts_settings_view.js create mode 100644 tests/browser_tts_settings.js create mode 100644 tests/piper_speech.test.js create mode 100644 tests/tts.test.js diff --git a/.gitignore b/.gitignore index 73ba79d..e17831e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Thumbs.db coverage/ dist/ tmp/ +piper/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f6a62e..910fc0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.23.0 - 2026-09-27 + +### Added + +- Local neural voice for announcements (Piper), free and fully offline, in Swedish and English. Settings -> Audio -> Voice engine: "Install" (or `npm run tts:install`) downloads Piper for the OS plus both voices once; choose "Local voice"; "Pre-generate phrases and driver names" before race day. If Piper is missing or slow, the browser voice is used automatically. Browser voice remains the default. + ## 1.22.0 - 2026-09-24 ### Added diff --git a/README.md b/README.md index 7bd2b16..41473ba 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ Short narrated screen recordings for new users, also linked from the in-app Guid These are regenerated with `node scripts/record-video-guides.mjs` (see the script header) whenever a covered flow changes enough that the recording no longer matches reality. +## Better Offline Voice (Piper) + +Announcements can use a local neural voice instead of the browser's system voice - free, no account, and it keeps working with no internet at the track. Swedish and English are installed together: + +```bash +npm run tts:install +``` + +or click **Install** under Settings -> Audio -> Voice engine. This downloads the standalone Piper release for your OS (Linux, Windows, macOS - no Python needed) plus the `sv_SE-nst-medium` and `en_US-lessac-medium` voices into `piper/` (about 180 MB). Internet is only needed while it installs. Then choose **Local voice** and click **Pre-generate phrases and driver names** before race day. If Piper is missing or slow, the browser voice takes over automatically. Optional overrides: `RC_PIPER_BIN` (path to a piper executable) and `RC_PIPER_VOICES_DIR` (extra voice folder). + ## Public Overlay And OBS The app now supports a dedicated public overlay route so you do not need to expose the full admin UI externally. diff --git a/README.sv.md b/README.sv.md index 8941e46..fc34702 100644 --- a/README.sv.md +++ b/README.sv.md @@ -99,6 +99,16 @@ Korta berättade skärminspelningar för nya användare, länkade även från de Dessa genereras om med `node scripts/record-video-guides.mjs` (se skriptets header) när ett täckt flöde ändras så mycket att inspelningen inte längre stämmer med verkligheten. +## Bättre Röst Offline (Piper) + +Utropen kan använda en lokal neural röst i stället för webbläsarens systemröst - gratis, inget konto, och den fungerar även utan internet vid banan. Svenska och engelska installeras tillsammans: + +```bash +npm run tts:install +``` + +eller klicka **Installera** under Inställningar -> Ljud -> Röstmotor. Det hämtar den fristående Piper-versionen för ditt operativsystem (Linux, Windows, macOS - ingen Python behövs) plus rösterna `sv_SE-nst-medium` och `en_US-lessac-medium` till `piper/` (cirka 180 MB). Internet behövs bara under installationen. Välj sedan **Lokal röst** och klicka **Förgenerera fraser och förarnamn** före racedagen. Saknas Piper eller dröjer den tar webbläsarens röst över automatiskt. Valfria inställningar: `RC_PIPER_BIN` (sökväg till en piper-fil) och `RC_PIPER_VOICES_DIR` (extra röstmapp). + ## Publik Overlay Och OBS Appen har nu en separat publik overlay-route så att du inte behöver exponera hela admin-UI:t externt. diff --git a/ROADMAP.md b/ROADMAP.md index f8b4268..ba3a090 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,12 +144,17 @@ Extended 2026-09-09: while updating the in-app Guide page to document this sessi Deferred 2026-09-09 at the user's request - requires a paid third-party neural TTS provider (API account/credentials) the user hasn't chosen yet, unlike everything else on this list. Revisit once a provider is picked. -- [ ] Optional backend-generated neural speech. -- [ ] Cache announcements and driver names before race day. -- [ ] Keep credentials on the backend, outside exports and public responses. -- [ ] Invalidate cached speech when language, voice or pronunciation changes. -- [ ] Provide provider status and a pre-generate action. -- [ ] Verify offline fallback and that playback never delays timing. +**Implemented 2026-09-27 without any paid service**, per the user: better voice, no purchase, must work with no internet (or when it's briefly gone), Swedish and English from the start. Solved with Piper, the same free local neural TTS already used for the video guides, running on the server. + +- [x] Optional backend-generated neural speech. `server/tts.js` (new) runs Piper per line, one generation at a time (a model load is CPU-heavy and must never starve the timing server), caches each line as a WAV under `data/tts/`, and dedupes identical in-flight requests. `POST /api/tts/speech` (a POST so it falls under the admin PIN), `GET /api/tts/status`. Voice picked per language from any installed `_--.onnx` + `.onnx.json`, preferring medium quality. +- [x] Swedish and English out of the box, on Linux and Windows. `npm run tts:install` / Settings -> Audio -> "Install" (`server/tts_install.js`, `scripts/install-piper.js`) downloads the standalone Piper release for the OS (Linux x64/arm64, Windows x64, macOS) plus `sv_SE-nst-medium` and `en_US-lessac-medium` into `/piper/` (gitignored). Needs internet only while it runs; everything is offline afterwards. The standalone C++ build needs no Python and generates a new line in ~0.3 s here (vs ~1 s for the pipx Python package), so even dynamic lines (lap times, names) are fast enough live. +- [x] Cache announcements and driver names before race day: "Pre-generate phrases and driver names" renders every fixed phrase from the sound pack's `voice_events` (sv + en) and every driver's spoken name in both languages. +- [x] Keep credentials on the backend - not applicable any more: there are no credentials at all. +- [x] Invalidate cached speech when language, voice or pronunciation changes: the cache key is voice + speed + exact text, so a new voice, speed or `speechName` simply maps to a new file. +- [x] Provide status and a pre-generate action: Settings -> Audio -> "Voice engine" panel (`src/tts_settings_view.js`) shows whether Piper and each language's voice are installed, the cached line count, and install/pre-generate/test buttons with live progress. +- [x] Verify offline fallback and that playback never delays timing: `src/piper_speech.js` plays the server's WAV; if Piper is missing, answers an error, or takes longer than 2.5 s, the line is spoken by the browser voice instead - no announcement is lost, and it's all async, off the timing path. Hooked into the single shared `speakTextHelper`, so both the legacy speaker cues and the race-audio engine use it. Browser voice stays the default until someone picks "Local voice". + +Verified 2026-09-27: `tests/tts.test.js` (voice discovery/picking, project-installed Piper preferred over PATH, one generation at a time, caching, in-flight dedupe, speed -> length scale, clear errors, pre-generate counts) and `tests/piper_speech.test.js` (fallback on 503, on a 2.5 s timeout, successful playback with volume/onEnd, busy refusal, interrupt cancels without the cancelled line falling back and talking over the new one). An integration test runs a real server against a fake Piper binary: WAV served, second request cached, 400/503 on bad input/missing language, 401 once the admin PIN is on. `tests/browser_tts_settings.js`: panel shows sv/en status, the engine choice persists across a reload. A real end-to-end run with the actual installer output (downloaded Piper + both voices) in a real browser: "Test the voice" played Piper audio 0.4 s after the click with no browser-voice fallback, and pre-generation produced 60 lines with 0 failures. The Windows zip's layout (`piper/piper.exe`) was checked against what the server looks for, but the installer hasn't been run on an actual Windows machine yet. ## 6. Permissions and mobile Race Control diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 5567894..6ea0810 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -230,6 +230,15 @@ Owns: - `middleware`: once a PIN exists, rejects unauthenticated non-GET `/api/*` requests and `GET /api/ammc/config` with `401 { error: "auth_required" }`; applies `RC_ADMIN_ALLOWED_CIDRS` (403 `ip_not_allowed`) to the same routes regardless of PIN - routes wired in `server.js`: `GET /api/auth/status`, `POST /api/auth/login`, `/logout`, `/pin`, `/disable` +### `server/tts.js`, `server/tts_install.js`, `scripts/install-piper.js` +Role: +- local neural text-to-speech via Piper (CommonJS, required by `server.js`) + +Owns: +- `tts.js`: finds the Piper binary (`RC_PIPER_BIN`, then `/piper/`, then PATH, then `~/.local/bin`) and voices (`RC_PIPER_VOICES_DIR`, `data/piper-voices`, `/piper/voices`, `~/.local/share/piper-voices`); generates one line at a time into a WAV cache under `data/tts/` keyed by voice + speed + text; dedupes identical in-flight requests; background pre-generation with progress +- `tts_install.js`: downloads the standalone Piper release for the OS and the `sv_SE-nst-medium` / `en_US-lessac-medium` voices into `/piper/`, skipping anything present; used by both `npm run tts:install` (`scripts/install-piper.js`) and `POST /api/tts/install` +- routes in `server.js`: `GET /api/tts/status` (open), `POST /api/tts/speech`, `POST /api/tts/pregenerate`, `POST /api/tts/install` (all PIN-protected once a PIN is set) + ### `scripts/record-video-guides.mjs` and `scripts/capture-screenshots.mjs` Role: - documentation tooling - not run in production, not imported by `server.js` or any `src/*.js` module @@ -551,6 +560,14 @@ Owns: - `showPinDialog`: the login modal - `mountAuthSettings`: the "Admin PIN" panel in Settings -> System & backup (enable, change, log out, disable) +### `src/piper_speech.js` and `src/tts_settings_view.js` +Type: +- local voice playback and its settings panel + +Owns: +- `piper_speech.js`: `speakWithPiper` fetches the server's WAV and plays it; on any error or after 2.5 s it calls the browser-voice fallback instead. `speakTextHelper` (`runtime_services.js`) routes through it when `settings.ttsEngine === "piper"`, so legacy speaker cues and the race-audio engine both use it +- `tts_settings_view.js`: the "Voice engine" panel in Settings -> Audio (engine choice, sv/en install status, install / pre-generate / test buttons) + ### `src/misc_views.js` Type: - misc page renderers @@ -2081,6 +2098,7 @@ Practical shape (field names as actually read/written in `src/settings.js` and ` speechVoiceUri: string, speechRate: number, speechPitch: number, + ttsEngine: "browser" | "piper", // voice engine; Piper falls back to the browser voice on any failure speakerPassingCueEnabled: boolean, speakerLeaderCueEnabled: boolean, speakerFinishCueEnabled: boolean, @@ -2717,12 +2735,6 @@ The suite uses Node's built-in `node:test` runner and does not add a test-framew Closed out 2026-09-12: `resultsBySession`'s competitor row schema (15.7), a full `settings` schema snapshot (15.9), and the SQLite table contract (15.10) are now documented; the browser regression suite (section 18) already covers every critical create/edit/save workflow (classes, drivers, cars, sponsor events, the race wizard, team management, Race Day start/pause/resume/finish, session-type-specific start sequences, Settings tabs, Event Manager tabs, the schedule panel, OBS popup, decoder reconnect). If the project grows further, the next useful additions to this document are: -- optional neural TTS provider integration for natural start, finish, and driver-name announcements (deferred at the user's request pending a chosen provider - see ROADMAP.md, "More natural race speech") -- backend-only provider credentials with no API key material in frontend state, exports, or public overlays -- server-side speech generation and content-addressed cache under `data/tts/` so race playback never waits for a cloud request -- cached audio invalidation when language, voice, phrase, or a driver's `speechName` changes -- offline fallback order: cached neural audio -> uploaded WAV/MP3 -> browser system voice -> passing beep -- provider health/status and a pre-generate-all-names action in Settings before race day - a full controller-to-view ID map for every form/button (large - the doc currently documents this per-feature as each area is touched, e.g. the manage-tab targets in 2.2's `event_manager_controller.js` entry, rather than as one exhaustive cross-reference) - permissions/authentication design once that work actually starts (ROADMAP.md, "Permissions and mobile Race Control" - deferred, no identity system exists yet) diff --git a/docs/security-performance-audit.md b/docs/security-performance-audit.md index 39c1a1e..0ff77b9 100644 --- a/docs/security-performance-audit.md +++ b/docs/security-performance-audit.md @@ -175,3 +175,11 @@ Deliberately still open, so the original findings remain valid for an installati - A cross-origin admin UI (`RC_ALLOWED_ORIGINS`) can't use the cookie, which is same-origin (`SameSite=Strict`) by design. Design notes: the PIN hash (scrypt, salted) lives in `data/auth.json`, never in the `app_state` blob, so it can't leak via `GET /api/state`, a JSON export or a backup. The login cookie is `HttpOnly`, `SameSite=Strict`, HMAC-signed with a per-installation secret (`data/auth_secret`), valid 30 days, and invalidated by any PIN change. Token signatures are format-checked strictly, because `Buffer.from(hex)` silently drops a trailing odd character (caught by the unit tests while this was built). + +## Follow-up - 2026-09-27: local voice (Piper) + +New surface, reviewed as it was built: + +- `POST /api/tts/speech`, `/api/tts/pregenerate` and `/api/tts/install` are writes, so they fall under the admin PIN once one is set. `GET /api/tts/status` is open like other reads; it exposes the Piper binary path and voice folders (same class of detail as `/api/health`'s database path - "Low" finding above). +- Piper runs through `execFile` with array arguments and the text on stdin, never a shell, so text can't inject commands. Text is capped at 300 characters, and generation is serialized (one Piper process at a time, 20 s timeout), which bounds how much CPU a flood of requests can take - but without a PIN anyone on the network can still keep that one queue busy. +- `npm run tts:install` / `POST /api/tts/install` download the Piper release from GitHub and voices from Hugging Face over HTTPS, without checksum verification - the same trust model as the "Medium: bundled AMMC binaries are trusted artifacts" finding above, and the same fix applies (pin expected SHA-256 hashes). diff --git a/package-lock.json b/package-lock.json index ab67b79..f74c81e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.22.0", + "version": "1.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.22.0", + "version": "1.23.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 61dcdf5..243aa27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.22.0", + "version": "1.23.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { @@ -21,6 +21,7 @@ "update": "node scripts/serverctl.js update", "verify": "node scripts/verify-install.js", "backup:restore": "node scripts/restore-backup.js", + "tts:install": "node scripts/install-piper.js", "audit:prod": "npm audit --omit=dev", "test": "node --experimental-default-type=module tests/run.js", "test:browser": "node --experimental-default-type=module tests/browser_smoke.js", diff --git a/scripts/install-piper.js b/scripts/install-piper.js new file mode 100644 index 0000000..b04b0bd --- /dev/null +++ b/scripts/install-piper.js @@ -0,0 +1,13 @@ +// Installs local neural voices for race announcements: the standalone Piper release for +// this OS plus Swedish and English voices, into piper/. Run once with internet access: +// npm run tts:install +const path = require("path"); +const { resolvePiperBinary } = require("../server/tts"); +const { installAll } = require("../server/tts_install"); + +const projectDir = path.join(__dirname, ".."); +installAll({ projectDir, hasBinary: Boolean(resolvePiperBinary(process.env.RC_PIPER_BIN || "", projectDir)), log: (line) => console.log(line) }) + .catch((error) => { + console.error(`Piper install failed: ${error.message}`); + process.exit(1); + }); diff --git a/server.js b/server.js index b3d3ca0..4eae2cf 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ const Database = require("better-sqlite3"); const publicTrainingProfile = require("./server/public_training_profile"); const { createBackupService } = require("./server/backup_service"); const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth"); +const { createTts, resolvePiperBinary } = require("./server/tts"); +const { installAll: installPiperAll } = require("./server/tts_install"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; @@ -16,6 +18,11 @@ const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMI const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" }); +const tts = createTts({ + dataDir: DB_DIR, + projectDir: __dirname, + voiceDirs: [process.env.RC_PIPER_VOICES_DIR, path.join(DB_DIR, "piper-voices"), path.join(__dirname, "piper", "voices"), path.join(os.homedir(), ".local", "share", "piper-voices")].filter(Boolean), +}); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const SRC_DIR = path.join(__dirname, "src"); const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack"); @@ -199,6 +206,38 @@ app.post("/api/auth/disable", (_req, res) => { res.json({ ok: true, enabled: false }); }); +app.get("/api/tts/status", (_req, res) => { + res.json({ ...tts.status(), install: ttsInstall }); +}); + +// POST (not GET) so generating audio falls under the admin PIN like every other write. +app.post("/api/tts/speech", async (req, res) => { + try { + const file = await tts.speech({ text: req.body?.text, language: req.body?.language, rate: req.body?.rate }); + res.type("audio/wav").sendFile(file); + } catch (error) { + res.status(error.code === "unavailable" ? 503 : error.code === "bad_request" ? 400 : 500).json({ error: error.message }); + } +}); + +// One-time download of Piper + Swedish/English voices, run in the background; progress is +// polled through /api/tts/status. Needs internet only while it runs. +const ttsInstall = { running: false, log: [], error: "" }; +app.post("/api/tts/install", (_req, res) => { + if (!ttsInstall.running) { + Object.assign(ttsInstall, { running: true, log: [], error: "" }); + installPiperAll({ projectDir: __dirname, hasBinary: Boolean(resolvePiperBinary(process.env.RC_PIPER_BIN || "", __dirname)), log: (line) => ttsInstall.log.push(line) }) + .catch((error) => { ttsInstall.error = error.message; }) + .finally(() => { ttsInstall.running = false; }); + } + res.status(202).json(ttsInstall); +}); + +app.post("/api/tts/pregenerate", (req, res) => { + tts.pregenerate(req.body?.items); + res.status(202).json(tts.status().pregenerate); +}); + app.get("/api/health", (_req, res) => { res.json({ ok: true, dbPath: DB_PATH }); }); diff --git a/server/tts.js b/server/tts.js new file mode 100644 index 0000000..2c3544c --- /dev/null +++ b/server/tts.js @@ -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 /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 _--. +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 }; diff --git a/server/tts_install.js b/server/tts_install.js new file mode 100644 index 0000000..ecd6923 --- /dev/null +++ b/server/tts_install.js @@ -0,0 +1,65 @@ +// Downloads the standalone Piper release for this OS and the Swedish + English voices +// into /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 }; diff --git a/src/app.js b/src/app.js index 5a4e668..b720d8d 100644 --- a/src/app.js +++ b/src/app.js @@ -1064,6 +1064,19 @@ const TRANSLATIONS = { "guide.tab_run": "Köra ett race", "guide.tab_training": "Träning & resultat", "guide.tab_videos": "Videoguider", + "tts.title": "Röstmotor", + "tts.hint": "Lokal röst (Piper) låter mer naturligt än webbläsarens röst och fungerar helt utan internet. Installera en gång (kräver internet just då), sedan körs allt lokalt. Om Piper saknas eller dröjer används webbläsarens röst automatiskt.", + "tts.engine": "Röstmotor", + "tts.engine_browser": "Webbläsarens röst", + "tts.engine_piper": "Lokal röst (Piper, offline)", + "tts.status_ready": "Piper är installerat.", + "tts.status_missing": "Piper eller röster saknas - klicka Installera.", + "tts.cached": "{count} repliker sparade", + "tts.install": "Installera Piper + svenska/engelska röster", + "tts.reinstall": "Kontrollera installation", + "tts.pregenerate": "Förgenerera fraser och förarnamn", + "tts.pregenerate_progress": "Förgenererat {done} av {total} ({failed} misslyckades)", + "tts.test": "Testa rösten", "auth.title": "Admin-PIN", "auth.hint": "Skyddar alla ändringar (race, resultat, inställningar, uppdatering) med en PIN. Läsning och publika overlay-/träningslänkar påverkas inte. Inloggningen sparas i webbläsaren i 30 dagar.", "auth.state_off": "Av - vem som helst på nätverket kan ändra.", @@ -1507,7 +1520,7 @@ const TRANSLATIONS = { "audio.max_delay": "Största fördröjning (sek)", "audio.per_competitor": "Olika passing-ton per förare/bil", "audio.test": "Testa", - "audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder webbläsarens installerade systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.", + "audio.note": "Fasta effekter spelas från ljudpaketets WAV-filer. Röstutrop använder vald röstmotor ovan - lokal Piper-röst eller webbläsarens systemröster. Val sparas automatiskt; kritiska utrop avbryter lägre prioriterat tal.", "audio.effect": "Effekt", "audio.voice": "Röst", "audio.track_call": "STOPP PÅ BANAN", @@ -1746,6 +1759,7 @@ const TRANSLATIONS = { "guide.audio_7": "7. Personbästa, snabbaste varv, banrekord, ledarbyte, tidsvarningar, depå in/ut, finish och podium hämtas från befintliga timinghändelser.", "guide.audio_8": "8. Ljudet spelas i operatörens webbläsare. Klicka Testa före racet, kontrollera ljudutgången och håll Tidtagning-fliken öppen.", "guide.audio_9": "9. Staggered start i ett kvalheat läser upp varje bils transpondernummer i startordning, cirka 3 sekunder mellan varje bil, direkt efter den vanliga starten - mass/position-start läser aldrig upp enskilda bilar.", + "guide.audio_10": "10. Bättre röst utan internet: under Inställningar -> Ljud -> Röstmotor, klicka Installera en gång (hämtar Piper och svenska + engelska röster, kräver internet just då) och välj Lokal röst. Klicka sedan Förgenerera fraser och förarnamn före racedagen. Allt körs sedan lokalt; saknas Piper eller dröjer den används webbläsarens röst automatiskt.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", "guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.", @@ -2645,6 +2659,19 @@ const TRANSLATIONS = { "guide.tab_run": "Run a race", "guide.tab_training": "Training & results", "guide.tab_videos": "Video guides", + "tts.title": "Voice engine", + "tts.hint": "The local voice (Piper) sounds more natural than the browser voice and works with no internet at all. Install once (needs internet just then), after that everything runs locally. If Piper is missing or slow, the browser voice is used automatically.", + "tts.engine": "Voice engine", + "tts.engine_browser": "Browser voice", + "tts.engine_piper": "Local voice (Piper, offline)", + "tts.status_ready": "Piper is installed.", + "tts.status_missing": "Piper or voices missing - click Install.", + "tts.cached": "{count} lines cached", + "tts.install": "Install Piper + Swedish/English voices", + "tts.reinstall": "Check installation", + "tts.pregenerate": "Pre-generate phrases and driver names", + "tts.pregenerate_progress": "Pre-generated {done} of {total} ({failed} failed)", + "tts.test": "Test the voice", "auth.title": "Admin PIN", "auth.hint": "Protects every change (races, results, settings, updates) with a PIN. Reading and public overlay/training links are unaffected. The login is remembered in this browser for 30 days.", "auth.state_off": "Off - anyone on the network can make changes.", @@ -3088,7 +3115,7 @@ const TRANSLATIONS = { "audio.max_delay": "Maximum delay (seconds)", "audio.per_competitor": "Different passing tone per driver/car", "audio.test": "Test", - "audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the browser installed system voices. Changes save automatically; critical announcements interrupt lower-priority speech.", + "audio.note": "Fixed effects use WAV files from the sound pack. Voice announcements use the voice engine chosen above - the local Piper voice or the browser's system voices. Changes save automatically; critical announcements interrupt lower-priority speech.", "audio.effect": "Effect", "audio.voice": "Voice", "audio.track_call": "TRACK CALL", @@ -3327,6 +3354,7 @@ const TRANSLATIONS = { "guide.audio_7": "7. Personal best, fastest lap, track record, leader changes, time warnings, pit entry/exit, finish, and podium reuse existing timing events.", "guide.audio_8": "8. Audio plays in the operator browser. Use Test before racing, verify the audio output, and keep the Timing tab open.", "guide.audio_9": "9. A staggered start in a qualifying heat calls out each car's transponder number in grid order, about 3 seconds apart, right after the official start - a mass/position start never calls out individual cars.", + "guide.audio_10": "10. Better voice without internet: under Settings -> Audio -> Voice engine, click Install once (downloads Piper plus Swedish and English voices, needs internet just then) and choose Local voice. Then click Pre-generate phrases and driver names before race day. Everything then runs locally; if Piper is missing or slow the browser voice is used automatically.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", "guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.", @@ -4080,6 +4108,7 @@ function loadState() { speechVoiceUri: String(parsed.settings?.speechVoiceUri || ""), speechRate: Math.max(0.65, Math.min(1.35, Number(parsed.settings?.speechRate || 0.95) || 0.95)), speechPitch: Math.max(0.7, Math.min(1.3, Number(parsed.settings?.speechPitch || 1) || 1)), + ttsEngine: parsed.settings?.ttsEngine === "piper" ? "piper" : "browser", speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false, @@ -4148,6 +4177,7 @@ function loadState() { speechVoiceUri: "", speechRate: 0.95, speechPitch: 1, + ttsEngine: "browser", speakerPassingCueEnabled: false, speakerLeaderCueEnabled: true, speakerFinishCueEnabled: true, diff --git a/src/misc_views.js b/src/misc_views.js index b782c97..72f9f7b 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -117,7 +117,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9"], "", { t })} + ${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8", "guide.audio_9", "guide.audio_10"], "", { t })}
diff --git a/src/piper_speech.js b/src/piper_speech.js new file mode 100644 index 0000000..7a423a4 --- /dev/null +++ b/src/piper_speech.js @@ -0,0 +1,60 @@ +// Plays announcements generated by the server's local Piper voice. Any failure - Piper not +// installed, generation too slow, network hiccup - falls back to the browser's own voice, +// so an announcement is never lost and never holds up timing. +const PIPER_TIMEOUT_MS = 2500; +let current = null; + +export function stopPiperSpeech() { + if (!current) return; + current.cancelled = true; + current.controller.abort(); + if (current.audio) current.audio.pause(); + if (current.url) URL.revokeObjectURL(current.url); + current = null; +} + +export function isPiperSpeaking() { + return Boolean(current); +} + +// Returns true when the line was accepted (it will play via Piper or the fallback). +export function speakWithPiper(text, { language, rate, volume = 1, interrupt, onEnd, onError, fallback }) { + if (current && !interrupt) return false; + stopPiperSpeech(); + const job = { cancelled: false, controller: new AbortController(), audio: null, url: "" }; + current = job; + const finish = (callback) => { + if (job.url) URL.revokeObjectURL(job.url); + if (current === job) current = null; + callback?.(); + }; + const timer = setTimeout(() => job.controller.abort(), PIPER_TIMEOUT_MS); + fetch("/api/tts/speech", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, language, rate }), + signal: job.controller.signal, + }) + .then((response) => { + if (!response.ok) throw new Error(`tts ${response.status}`); + return response.blob(); + }) + .then((blob) => { + clearTimeout(timer); + if (job.cancelled) return; + job.url = URL.createObjectURL(blob); + job.audio = new Audio(job.url); + job.audio.volume = Math.max(0, Math.min(1, Number(volume))); + job.audio.onended = () => finish(onEnd); + job.audio.onerror = () => finish(onError); + return job.audio.play(); + }) + .catch(() => { + clearTimeout(timer); + if (job.cancelled) return; + finish(() => { + if (!fallback?.()) onError?.(); + }); + }); + return true; +} diff --git a/src/runtime_services.js b/src/runtime_services.js index 8e89ffc..7d709c4 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -1,3 +1,4 @@ +import { speakWithPiper, isPiperSpeaking } from "./piper_speech.js"; export function createDefaultAmmcConfigHelper() { return { managedEnabled: false, @@ -132,6 +133,7 @@ export function applyPersistedStateHelper(persisted, deps) { speechVoiceUri: String(persisted.settings?.speechVoiceUri || state.settings.speechVoiceUri || ""), speechRate: Math.max(0.65, Math.min(1.35, Number(persisted.settings?.speechRate || state.settings.speechRate || 0.95) || 0.95)), speechPitch: Math.max(0.7, Math.min(1.3, Number(persisted.settings?.speechPitch || state.settings.speechPitch || 1) || 1)), + ttsEngine: persisted.settings?.ttsEngine === "piper" ? "piper" : "browser", speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false, @@ -364,8 +366,21 @@ export function getSessionAnnouncementText(session, phase, { t }) { } export function speakTextHelper(text, { state, currentLanguage }, options = {}) { - if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) return false; + if (!state.settings.audioEnabled || !text) return false; + if (state.settings.ttsEngine === "piper" && !options.skipPiper) { + return speakWithPiper(text, { + language: String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en", + rate: Math.max(0.65, Math.min(1.35, Number(options.rate ?? state.settings.speechRate ?? 0.95) || 0.95)), + volume: options.volume ?? 1, + interrupt: options.interrupt, + onEnd: options.onEnd, + onError: options.onError, + fallback: () => speakTextHelper(text, { state, currentLanguage }, { ...options, skipPiper: true, interrupt: true }), + }); + } + if (!("speechSynthesis" in window)) return false; const synth = window.speechSynthesis; + if (!options.interrupt && isPiperSpeaking()) return false; if (!options.interrupt && (synth.speaking || synth.pending)) return false; if (options.interrupt) synth.cancel(); const utterance = new SpeechSynthesisUtterance(text); diff --git a/src/settings.js b/src/settings.js index 536b557..11f0d93 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,5 +1,6 @@ import { decoderDiagnosticsMarkup, mountDecoderDiagnostics } from "./decoder_diagnostics_view.js"; import { mountAuthSettings } from "./auth_view.js"; +import { mountTtsSettings } from "./tts_settings_view.js"; let activeSettingsTab = "connection"; @@ -233,6 +234,10 @@ export function renderSettings(deps) {
+
+

${t("tts.title")}

+
+

${t("audio.race_sounds")}

@@ -641,6 +646,7 @@ Erik Eriksson;654321;4WD;Associated;; }); applySettingsTab(settingsTabButtons.some((button) => button.dataset.settingsTab === activeSettingsTab) ? activeSettingsTab : "connection"); mountAuthSettings(document.getElementById("authSettings"), { t, escapeHtml }); + mountTtsSettings(document.getElementById("ttsSettings"), { state, t, escapeHtml, saveState, speakText }); const applyRaceAudioFormValues = (form) => { const events = {}; diff --git a/src/tts_settings_view.js b/src/tts_settings_view.js new file mode 100644 index 0000000..de8746f --- /dev/null +++ b/src/tts_settings_view.js @@ -0,0 +1,79 @@ +// "Voice engine" panel in Settings -> Audio: choose browser voices or the local Piper +// voices, install Piper + Swedish/English voices once, and pre-generate fixed phrases and +// driver names so race day never waits on generation. +const LANGUAGES = ["sv", "en"]; +// Fixed per language on purpose - the sample must be in the voice being tested, not the UI language. +const SAMPLES = { sv: "Heat ett startar om tio sekunder. Nytt varvrekord för Alex Karlsson.", en: "Heat one starts in ten seconds. New lap record for Alex Karlsson." }; + +async function getStatus() { + return fetch("/api/tts/status").then((res) => res.json()).catch(() => null); +} + +async function buildPregenerateItems(state) { + const manifest = await fetch("/race-sounds/sound_manifest.json").then((res) => res.json()).catch(() => ({})); + const items = []; + LANGUAGES.forEach((language) => { + Object.values(manifest?.voice_events?.[language] || {}) + .filter((text) => typeof text === "string" && text && !text.includes("{")) + .forEach((text) => items.push({ text, language })); + (state.drivers || []).forEach((driver) => { + const name = String(driver.speechName || driver.name || "").trim(); + if (name) items.push({ text: name, language }); + }); + }); + return items; +} + +export async function mountTtsSettings(container, deps) { + if (!container) return; + const { state, t, escapeHtml, saveState, speakText } = deps; + const status = await getStatus(); + if (!container.isConnected) return; + const remount = () => mountTtsSettings(container, deps); + const hasLanguage = (language) => Boolean(status?.voices?.some((voice) => voice.language === language)); + const engine = state.settings.ttsEngine === "piper" ? "piper" : "browser"; + const install = status?.install || {}; + const pregen = status?.pregenerate || {}; + const busy = install.running || pregen.running; + + container.innerHTML = ` +

${escapeHtml(t("tts.hint"))}

+
+ +
+

${escapeHtml(status?.available ? t("tts.status_ready") : t("tts.status_missing"))}

+

${LANGUAGES.map((language) => `${language === "sv" ? "Svenska" : "English"}: ${hasLanguage(language) ? "✓" : "✗"}`).join(" · ")} · ${escapeHtml(t("tts.cached", { count: status?.cachedLines || 0 }))}

+
+
+
+ + + +
+ ${install.running || install.log?.length ? `
${escapeHtml((install.log || []).join("\n"))}${install.error ? `\n${escapeHtml(install.error)}` : ""}
` : ""} + ${pregen.total ? `

${escapeHtml(t("tts.pregenerate_progress", { done: pregen.done, total: pregen.total, failed: pregen.failed }))}

` : ""}`; + + container.querySelector("#ttsEngine").addEventListener("change", (event) => { + state.settings.ttsEngine = event.target.value === "piper" ? "piper" : "browser"; + saveState(); + }); + container.querySelector("#ttsInstall").addEventListener("click", async () => { + await fetch("/api/tts/install", { method: "POST" }); + remount(); + }); + container.querySelector("#ttsPregenerate").addEventListener("click", async () => { + await fetch("/api/tts/pregenerate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: await buildPregenerateItems(state) }) }); + remount(); + }); + container.querySelector("#ttsTest").addEventListener("click", () => { + const language = state.settings.raceAudio?.language === "en" ? "en" : "sv"; + speakText(SAMPLES[language], { interrupt: true, language }); + }); + // Keep polling while a background install/pre-generation is running. + if (busy) setTimeout(() => { if (container.isConnected) remount(); }, 1500); +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 7d1b76e..9fed12c 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -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 }); diff --git a/tests/browser_tts_settings.js b/tests/browser_tts_settings.js new file mode 100644 index 0000000..350d9a6 --- /dev/null +++ b/tests/browser_tts_settings.js @@ -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"); +} diff --git a/tests/piper_speech.test.js b/tests/piper_speech.test.js new file mode 100644 index 0000000..6ac6393 --- /dev/null +++ b/tests/piper_speech.test.js @@ -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(); + } +}); diff --git a/tests/run.js b/tests/run.js index 5d221a0..ce06d9a 100644 --- a/tests/run.js +++ b/tests/run.js @@ -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"; diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 7e6f79c..baddf80 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.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 }); + } +}); diff --git a/tests/tts.test.js b/tests/tts.test.js new file mode 100644 index 0000000..0751674 --- /dev/null +++ b/tests/tts.test.js @@ -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(); } +});