From 196437d25b4fe82ba02e7d1c71ec614ec2d0826c Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 27 Sep 2026 20:29:59 +0200 Subject: [PATCH] feat(audio): choose a male or female local voice per language The installer now fetches one male and one female Piper voice per language (Swedish NST/Lisa, English Ryan/Lessac), with genders confirmed by measuring each voice's pitch rather than guessed from the name. Settings -> Audio gets a voice selector per language with its own Test button that always plays the Piper voice. Male is preselected for both. The integration test now runs its server with an empty home directory, so voices installed on the developer's own machine can't leak into it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 2 +- README.sv.md | 2 +- ROADMAP.md | 11 ++++ docs/javascript-architecture.md | 3 + package-lock.json | 4 +- package.json | 2 +- server.js | 2 +- server/tts.js | 20 ++++-- server/tts_install.js | 9 ++- src/app.js | 20 +++++- src/piper_speech.js | 4 +- src/runtime_services.js | 6 +- src/styles.css | 10 +++ src/tts_settings_view.js | 64 +++++++++++++++++--- tests/server_persistence.integration.test.js | 13 ++-- tests/tts.test.js | 27 ++++++--- 17 files changed, 167 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 910fc0f..629a3be 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.24.0 - 2026-09-27 + +### Added + +- Choose a male or female local voice per language under Settings -> Audio -> Voice engine ("Swedish voice" / "English voice", each with its own Test button). The installer now fetches one male and one female voice per language: Swedish NST (male, default) and Lisa (female), English Ryan (male, default) and Lessac (female). Existing installs: click "Install" once more to fetch the two new voices; nothing already downloaded is fetched again. + ## 1.23.0 - 2026-09-27 ### Added diff --git a/README.md b/README.md index 41473ba..ba0a658 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Announcements can use a local neural voice instead of the browser's system voice 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). +or click **Install** under Settings -> Audio -> Voice engine. This downloads the standalone Piper release for your OS (Linux, Windows, macOS - no Python needed) plus one male and one female voice per language - Swedish NST (male) and Lisa (female), English Ryan (male) and Lessac (female) - into `piper/` (about 300 MB). Internet is only needed while it installs. Then choose **Local voice**, pick the Swedish and English voice you want (male is preselected), 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 diff --git a/README.sv.md b/README.sv.md index fc34702..d1c1624 100644 --- a/README.sv.md +++ b/README.sv.md @@ -107,7 +107,7 @@ Utropen kan använda en lokal neural röst i stället för webbläsarens systemr 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). +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 en manlig och en kvinnlig röst per språk - svenska NST (man) och Lisa (kvinna), engelska Ryan (man) och Lessac (kvinna) - till `piper/` (cirka 300 MB). Internet behövs bara under installationen. Välj sedan **Lokal röst**, välj svensk och engelsk röst (man är förvald) 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 diff --git a/ROADMAP.md b/ROADMAP.md index ba3a090..07a8adf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,17 @@ Deferred 2026-09-09 at the user's request - requires a paid third-party neural T 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. +### Follow-up 2026-09-27: male or female voice per language + +Asked by the user right after confirming Piper works on both Linux and Windows: how to switch between a male and a female voice. Until then only one voice per language was installed and picked automatically. + +- [x] Checked genders instead of assuming them from the voice names: generated the same sentence with each candidate and measured the median pitch (male ~85-155 Hz, female ~165-255 Hz). Swedish `nst` 81 Hz (male), `lisa` 184 Hz and `alma` 187 Hz (female); English `ryan` 152 Hz (male), `lessac` 208 Hz (female). +- [x] The installer's voice catalog (`server/tts_install.js`) now has one male and one female voice per language, each with a gender and a label. Defaults chosen by the user: male on both (NST, Ryan). +- [x] `pickVoice` uses the voice chosen in Settings if it's installed and speaks that language, otherwise the catalog default, then any medium voice. The choice travels as `voice` in `POST /api/tts/speech`; the cache is already keyed per voice, so switching never replays the other voice's lines. +- [x] Settings -> Audio -> Voice engine: a voice selector per language ("NST (male)", "Lisa (female)", ...) with its own Test button that always plays the Piper voice, never the browser fallback, so picking and hearing a voice can't be confused. New settings `ttsVoiceSv` / `ttsVoiceEn`, added to all three settings allowlists (anything not listed there is silently dropped on load). Pre-generation uses the chosen voices. + +Verified 2026-09-27: unit tests cover catalog gender labels, the chosen voice winning, a chosen voice from the wrong language or no longer installed falling back, and the same text in another voice being a separate cached line. The integration test now checks that the chosen voice reaches the Piper process through the real server - and while updating it, found the test depended on the developer's own machine (it picked up voices from `~/.local/share/piper-voices`), fixed by giving the test server its own empty home directory. A real browser run switched the Swedish voice from automatic to Lisa and measured the audio actually played: 80 Hz -> 182 Hz, and the choice was saved on the server. + ## 6. Permissions and mobile Race Control Deferred 2026-09-09 at the user's request - the largest remaining item, on par with or bigger than the full optimistic-locking rework already deferred under section 2. Today there is no login or authorization at all: anyone reaching the server on the network has full admin access. Building this needs an identity system that doesn't exist yet (no user accounts, no sessions), server-side authorization on every mutating endpoint (not just hiding UI controls), and a genuinely new "compact mobile Race Control" surface (start/stop, track call, flags, next heat) - plus a real decision on how login should work in practice on a shared trackside laptop (PIN codes per role? named accounts? offline/LAN-only requirement?) before any of it can be designed, let alone built. Revisit as its own dedicated design conversation. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 6ea0810..e966010 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -235,6 +235,7 @@ Role: - local neural text-to-speech via Piper (CommonJS, required by `server.js`) Owns: +- voice catalog in `tts_install.js`: one male + one female voice per language with gender/label (genders measured by pitch, not guessed); `pickVoice` prefers the voice chosen in Settings, then the catalog default - `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) @@ -2099,6 +2100,8 @@ Practical shape (field names as actually read/written in `src/settings.js` and ` speechRate: number, speechPitch: number, ttsEngine: "browser" | "piper", // voice engine; Piper falls back to the browser voice on any failure + ttsVoiceSv: string, // chosen Piper voice name per language; "" = catalog default (male) + ttsVoiceEn: string, speakerPassingCueEnabled: boolean, speakerLeaderCueEnabled: boolean, speakerFinishCueEnabled: boolean, diff --git a/package-lock.json b/package-lock.json index f74c81e..64c5920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.23.0", + "version": "1.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.23.0", + "version": "1.24.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 243aa27..6ef8850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.23.0", + "version": "1.24.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index 4eae2cf..38f0ba8 100644 --- a/server.js +++ b/server.js @@ -213,7 +213,7 @@ app.get("/api/tts/status", (_req, res) => { // 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 }); + const file = await tts.speech({ text: req.body?.text, language: req.body?.language, rate: req.body?.rate, voice: req.body?.voice }); res.type("audio/wav").sendFile(file); } catch (error) { res.status(error.code === "unavailable" ? 503 : error.code === "bad_request" ? 400 : 500).json({ error: error.message }); diff --git a/server/tts.js b/server/tts.js index 2c3544c..7df60cf 100644 --- a/server/tts.js +++ b/server/tts.js @@ -7,6 +7,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { execFile } = require("child_process"); +const { VOICES: VOICE_CATALOG } = require("./tts_install"); const MAX_TEXT_LENGTH = 300; const GENERATE_TIMEOUT_MS = 20000; @@ -34,16 +35,23 @@ function listVoices(voiceDirs) { 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() }); + const known = VOICE_CATALOG.find((voice) => voice.name === name); + if (!voices.has(name)) voices.set(name, { name, file: path.join(dir, entry), language: name.split(/[_-]/)[0].toLowerCase(), gender: known?.gender || "", label: known?.label || name, isDefault: Boolean(known?.default) }); }); }); return [...voices.values()].sort((a, b) => a.name.localeCompare(b.name)); } -function pickVoice(voices, language) { +// The voice chosen in Settings if it's installed and speaks this language; otherwise the +// catalog default for the language, then any medium-quality voice, then anything. +function pickVoice(voices, language, preferredName = "") { 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; + return matching.find((voice) => voice.name === preferredName) + || matching.find((voice) => voice.isDefault) + || matching.find((voice) => voice.name.endsWith("-medium")) + || matching[0] + || null; } function createTts({ dataDir, projectDir, piperBin = process.env.RC_PIPER_BIN || "", voiceDirs = [], run = execFile }) { @@ -56,7 +64,7 @@ function createTts({ dataDir, projectDir, piperBin = process.env.RC_PIPER_BIN || 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 }; + return { available: Boolean(binary && voices.length), binary, voices: voices.map(({ name, language, gender, label, isDefault }) => ({ name, language, gender, label, isDefault })), voiceDirs, cachedLines }; } // The voice name and speed are part of the key, so changing either never replays a stale line. @@ -88,11 +96,11 @@ function createTts({ dataDir, projectDir, piperBin = process.env.RC_PIPER_BIN || // 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 }) { + async function speech({ text, language, rate, voice: preferredVoice }) { 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); + const voice = pickVoice(listVoices(voiceDirs), language, preferredVoice); 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; diff --git a/server/tts_install.js b/server/tts_install.js index ecd6923..ebe645f 100644 --- a/server/tts_install.js +++ b/server/tts_install.js @@ -16,9 +16,14 @@ const PIPER_ARCHIVES = { "darwin-arm64": "piper_macos_aarch64.tar.gz", }; const VOICE_BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/main"; +// One male and one female voice per language. Genders were checked by measuring each +// voice's median pitch (nst 81 Hz, ryan 152 Hz; lisa 184 Hz, lessac 208 Hz), not assumed +// from the name. `default` is the voice used until someone picks another in Settings. const VOICES = [ - { name: "sv_SE-nst-medium", path: "sv/sv_SE/nst/medium" }, - { name: "en_US-lessac-medium", path: "en/en_US/lessac/medium" }, + { name: "sv_SE-nst-medium", path: "sv/sv_SE/nst/medium", language: "sv", gender: "male", label: "NST", default: true }, + { name: "sv_SE-lisa-medium", path: "sv/sv_SE/lisa/medium", language: "sv", gender: "female", label: "Lisa" }, + { name: "en_US-ryan-medium", path: "en/en_US/ryan/medium", language: "en", gender: "male", label: "Ryan", default: true }, + { name: "en_US-lessac-medium", path: "en/en_US/lessac/medium", language: "en", gender: "female", label: "Lessac" }, ]; async function download(url, target) { diff --git a/src/app.js b/src/app.js index b720d8d..738b385 100644 --- a/src/app.js +++ b/src/app.js @@ -1077,6 +1077,12 @@ const TRANSLATIONS = { "tts.pregenerate": "Förgenerera fraser och förarnamn", "tts.pregenerate_progress": "Förgenererat {done} av {total} ({failed} misslyckades)", "tts.test": "Testa rösten", + "tts.voice_sv": "Svensk röst", + "tts.voice_en": "Engelsk röst", + "tts.voice_auto": "Automatisk", + "tts.gender_male": "man", + "tts.gender_female": "kvinna", + "tts.test_failed": "Kunde inte spela upp den lokala rösten - kontrollera installationen.", "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.", @@ -1759,7 +1765,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.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. Välj manlig eller kvinnlig röst per språk under Svensk röst / Engelsk röst och lyssna med Testa rösten. 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.", @@ -2672,6 +2678,12 @@ const TRANSLATIONS = { "tts.pregenerate": "Pre-generate phrases and driver names", "tts.pregenerate_progress": "Pre-generated {done} of {total} ({failed} failed)", "tts.test": "Test the voice", + "tts.voice_sv": "Swedish voice", + "tts.voice_en": "English voice", + "tts.voice_auto": "Automatic", + "tts.gender_male": "male", + "tts.gender_female": "female", + "tts.test_failed": "Could not play the local voice - check the installation.", "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.", @@ -3354,7 +3366,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.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. Pick a male or female voice per language under Swedish voice / English voice and listen with Test the 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.", @@ -4109,6 +4121,8 @@ function loadState() { 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", + ttsVoiceSv: String(parsed.settings?.ttsVoiceSv || ""), + ttsVoiceEn: String(parsed.settings?.ttsVoiceEn || ""), speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false, @@ -4178,6 +4192,8 @@ function loadState() { speechRate: 0.95, speechPitch: 1, ttsEngine: "browser", + ttsVoiceSv: "", + ttsVoiceEn: "", speakerPassingCueEnabled: false, speakerLeaderCueEnabled: true, speakerFinishCueEnabled: true, diff --git a/src/piper_speech.js b/src/piper_speech.js index 7a423a4..3ef24c4 100644 --- a/src/piper_speech.js +++ b/src/piper_speech.js @@ -18,7 +18,7 @@ export function isPiperSpeaking() { } // 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 }) { +export function speakWithPiper(text, { language, voice = "", rate, volume = 1, interrupt, onEnd, onError, fallback }) { if (current && !interrupt) return false; stopPiperSpeech(); const job = { cancelled: false, controller: new AbortController(), audio: null, url: "" }; @@ -32,7 +32,7 @@ export function speakWithPiper(text, { language, rate, volume = 1, interrupt, on fetch("/api/tts/speech", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text, language, rate }), + body: JSON.stringify({ text, language, voice, rate }), signal: job.controller.signal, }) .then((response) => { diff --git a/src/runtime_services.js b/src/runtime_services.js index 7d709c4..14d7160 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -134,6 +134,8 @@ export function applyPersistedStateHelper(persisted, deps) { 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", + ttsVoiceSv: String(persisted.settings?.ttsVoiceSv || ""), + ttsVoiceEn: String(persisted.settings?.ttsVoiceEn || ""), speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false, @@ -368,8 +370,10 @@ export function getSessionAnnouncementText(session, phase, { t }) { export function speakTextHelper(text, { state, currentLanguage }, options = {}) { if (!state.settings.audioEnabled || !text) return false; if (state.settings.ttsEngine === "piper" && !options.skipPiper) { + const piperLanguage = String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en"; return speakWithPiper(text, { - language: String(options.language || currentLanguage()).toLowerCase().startsWith("sv") ? "sv" : "en", + language: piperLanguage, + voice: piperLanguage === "sv" ? state.settings.ttsVoiceSv : state.settings.ttsVoiceEn, 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, diff --git a/src/styles.css b/src/styles.css index 073d93f..f8e2ade 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4428,3 +4428,13 @@ select:focus { .auth-dialog p { margin: 0; } + +.tts-voice-row { + display: flex; + gap: 10px; + align-items: flex-end; +} + +.tts-voice-row label { + flex: 1; +} diff --git a/src/tts_settings_view.js b/src/tts_settings_view.js index de8746f..f94ab82 100644 --- a/src/tts_settings_view.js +++ b/src/tts_settings_view.js @@ -1,6 +1,8 @@ // "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. +import { speakWithPiper } from "./piper_speech.js"; + 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." }; @@ -9,16 +11,19 @@ async function getStatus() { return fetch("/api/tts/status").then((res) => res.json()).catch(() => null); } +const voiceSettingKey = (language) => (language === "sv" ? "ttsVoiceSv" : "ttsVoiceEn"); + async function buildPregenerateItems(state) { const manifest = await fetch("/race-sounds/sound_manifest.json").then((res) => res.json()).catch(() => ({})); const items = []; LANGUAGES.forEach((language) => { + const voice = state.settings[voiceSettingKey(language)] || ""; Object.values(manifest?.voice_events?.[language] || {}) .filter((text) => typeof text === "string" && text && !text.includes("{")) - .forEach((text) => items.push({ text, language })); + .forEach((text) => items.push({ text, language, voice })); (state.drivers || []).forEach((driver) => { const name = String(driver.speechName || driver.name || "").trim(); - if (name) items.push({ text: name, language }); + if (name) items.push({ text: name, language, voice }); }); }); return items; @@ -30,7 +35,25 @@ export async function mountTtsSettings(container, 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 voicesFor = (language) => (status?.voices || []).filter((voice) => voice.language === language); + const hasLanguage = (language) => voicesFor(language).length > 0; + // Complete = a male and a female voice installed for every language. + const complete = LANGUAGES.every((language) => ["male", "female"].every((gender) => voicesFor(language).some((voice) => voice.gender === gender))); + const voiceLabel = (voice) => (voice.gender ? `${voice.label} (${t(voice.gender === "male" ? "tts.gender_male" : "tts.gender_female")})` : voice.label); + const voiceSelect = (language) => { + const chosen = state.settings[voiceSettingKey(language)] || ""; + const automatic = voicesFor(language).find((voice) => voice.isDefault) || voicesFor(language)[0]; + return ` +
+ + +
`; + }; const engine = state.settings.ttsEngine === "piper" ? "piper" : "browser"; const install = status?.install || {}; const pregen = status?.pregenerate || {}; @@ -51,10 +74,14 @@ export async function mountTtsSettings(container, deps) {
- + -
+
+ ${voiceSelect("sv")} + ${voiceSelect("en")} +
+ ${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 }))}

` : ""}`; @@ -70,9 +97,30 @@ export async function mountTtsSettings(container, deps) { 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 }); + container.querySelectorAll("[data-tts-voice]").forEach((select) => { + select.addEventListener("change", () => { + state.settings[voiceSettingKey(select.dataset.ttsVoice)] = select.value; + saveState(); + }); + }); + // Always tests the Piper voice itself (no browser fallback), whatever engine is active, + // so picking a voice and hearing it are never confused with the browser's voice. + container.querySelectorAll("[data-tts-test]").forEach((button) => { + button.addEventListener("click", () => { + const language = button.dataset.ttsTest; + const error = container.querySelector("#ttsTestError"); + error.hidden = true; + speakWithPiper(SAMPLES[language], { + language, + voice: state.settings[voiceSettingKey(language)] || "", + interrupt: true, + fallback: () => { + error.textContent = t("tts.test_failed"); + error.hidden = false; + return true; + }, + }); + }); }); // Keep polling while a background install/pre-generation is running. if (busy) setTimeout(() => { if (container.isConnected) remount(); }, 1500); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index baddf80..0c84a0d 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -577,19 +577,22 @@ test("local Piper speech is served as WAV, cached, and falls under the admin PIN 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"]) { + for (const name of ["sv_SE-test-medium", "sv_SE-other-medium", "en_US-test-medium"]) { fs.writeFileSync(join(voicesDir, `${name}.onnx`), ""); fs.writeFileSync(join(voicesDir, `${name}.onnx.json`), "{}"); } + // HOME points at the temp dir so voices installed on the developer's own machine + // (~/.local/share/piper-voices) can't leak into the test. // 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]; +const model = require("path").basename(process.argv[process.argv.indexOf("-m") + 1], ".onnx"); let text = ""; process.stdin.on("data", (chunk) => { text += chunk; }); -process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x"); fs.writeFileSync(out, "RIFF" + text); }); +process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x"); fs.writeFileSync(out, "RIFF:" + model + ":" + text); }); `); fs.chmodSync(fakePiper, 0o755); @@ -597,7 +600,7 @@ process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x 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 }, + 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, HOME: dataDir, USERPROFILE: dataDir }, 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) }); @@ -612,9 +615,11 @@ process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x 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 first.text(), "RIFF:sv_SE-other-medium:Heat ett startar.\n", "with no choice and no catalog default installed, the first medium Swedish voice is used"); 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"); + const chosen = await speech({ text: "Heat ett startar.", language: "sv", rate: 1, voice: "sv_SE-test-medium" }); + assert.equal(await chosen.text(), "RIFF:sv_SE-test-medium:Heat ett startar.\n", "the voice chosen in Settings reaches Piper"); 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"); diff --git a/tests/tts.test.js b/tests/tts.test.js index 0751674..bf78648 100644 --- a/tests/tts.test.js +++ b/tests/tts.test.js @@ -31,7 +31,7 @@ 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"]) { + for (const name of ["sv_SE-nst-medium", "sv_SE-lisa-medium", "sv_SE-alma-low", "en_US-lessac-medium", "en_US-ryan-medium", "de_DE-thorsten-medium"]) { writeFileSync(join(voices, `${name}.onnx`), ""); writeFileSync(join(voices, `${name}.onnx.json`), "{}"); } @@ -43,13 +43,23 @@ function setup() { 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", () => { +test("voices need a matching .onnx.json, carry catalog gender labels, and the chosen voice wins", () => { 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.deepEqual(voices.map((voice) => voice.name), ["de_DE-thorsten-medium", "en_US-lessac-medium", "en_US-ryan-medium", "sv_SE-alma-low", "sv_SE-lisa-medium", "sv_SE-nst-medium"]); + const byName = Object.fromEntries(voices.map((voice) => [voice.name, voice])); + assert.equal(byName["sv_SE-nst-medium"].gender, "male"); + assert.equal(byName["sv_SE-lisa-medium"].gender, "female"); + assert.equal(byName["en_US-ryan-medium"].gender, "male"); + assert.equal(byName["en_US-lessac-medium"].gender, "female"); + assert.equal(byName["sv_SE-alma-low"].gender, "", "a voice outside the catalog has no guessed gender"); + + assert.equal(pickVoice(voices, "sv").name, "sv_SE-nst-medium", "Swedish defaults to the male NST voice"); + assert.equal(pickVoice(voices, "en-US").name, "en_US-ryan-medium", "English defaults to the male Ryan voice"); + assert.equal(pickVoice(voices, "sv", "sv_SE-lisa-medium").name, "sv_SE-lisa-medium", "the voice chosen in Settings is used"); + assert.equal(pickVoice(voices, "sv", "en_US-lessac-medium").name, "sv_SE-nst-medium", "a chosen voice in another language is ignored"); + assert.equal(pickVoice(voices, "sv", "sv_SE-removed-medium").name, "sv_SE-nst-medium", "an uninstalled choice falls back to the default"); assert.equal(pickVoice(voices, "fi"), null); } finally { env.cleanup(); } }); @@ -79,12 +89,15 @@ test("each line is generated once, cached, deduplicated, and generated one at a 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.ok(calls[1].args.includes(join(env.voices, "en_US-ryan-medium.onnx")), "the English voice is used for English text"); + await tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1, voice: "sv_SE-lisa-medium" }); + assert.equal(calls.length, 5, "the same text in another voice is a separate cached line"); + assert.ok(calls[4].args.includes(join(env.voices, "sv_SE-lisa-medium.onnx"))); 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(status.cachedLines, 5); assert.equal(readdirSync(join(env.root, "data", "tts")).filter((file) => file.endsWith(".tmp")).length, 0, "no temp files are left behind"); } finally { env.cleanup(); } });