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(); } });