diff --git a/CHANGELOG.md b/CHANGELOG.md index 925adcc..6e2e106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.29.0 - 2026-09-30 + +### Added + +- More local Piper voices, downloaded one at a time (about 63 MB each) under Settings -> Audio -> Voice engine -> More voices: + - Swedish: Alma (female). + - British: Alan and Northern English (male), Cori (female). + - Scottish: Alba (female). + - American: Joe, John and Norman (male). + + The voice lists now show gender and accent, e.g. "Alan (male, British)". The standard install still fetches only NST, Lisa, Ryan and Lessac. + ## 1.28.0 - 2026-09-30 ### Added diff --git a/README.md b/README.md index ba0a658..a912b60 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 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). +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. More voices can be downloaded one at a time under **More voices** in the same panel (about 63 MB each): Swedish Alma (female), British Alan and Northern English (male), Cori (female), Scottish Alba (female), and American Joe, John and Norman (male). 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 d1c1624..a268399 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 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). +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. Fler röster hämtas en i taget under **Fler röster** i samma panel (ca 63 MB var): svenska Alma (kvinna), brittiska Alan och Northern English (män), Cori (kvinna), skotska Alba (kvinna) och amerikanska Joe, John och Norman (män). 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 3f6bb36..51998be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -631,6 +631,24 @@ Verified 2026-09-30: - 238 unit tests pass, including new tests for the silenced set, speaker mode, practice and no session; that a saved `audioEnabled: false` no longer mutes speech; and that race start no longer plays the old tone and voice. - The 49 browser scenarios pass. The settings test now checks that the legacy panel is gone, that race calls default to passing tone only, and that "Speaker" survives a save. +## More local voices + +Asked by the user 2026-09-30, choosing "more voices" over cloning celebrity voices. Cloning a real person's voice without consent was declined. + +Checked the official Piper voice list (`voices.json` on Hugging Face). Only single-speaker voices were considered, because multi-speaker models would need a speaker picker. Candidates were downloaded to a scratch folder, and each one generated a sample line with the real Piper binary. Genders were then set from the measured median pitch, not from the name: +- male: Alan 103 Hz, Northern English 110 Hz, Joe 98 Hz, John 117 Hz, Norman 103 Hz; +- female: Alma 185 Hz, Alba 199 Hz, Cori 202 Hz. + +- [x] Catalog in `server/tts_install.js` with `accent` and an `extra` flag. A standard install (the Install button and `npm run tts:install`) still fetches only NST, Lisa, Ryan and Lessac. Extra voices are fetched one at a time via `POST /api/tts/install { voice }`, and an unknown name is rejected with 400. +- [x] `tts.status()` reports `downloadable`: catalog voices not yet installed, with gender and accent. +- [x] Settings → Audio → Voice engine shows "More voices" with a Download button per voice (progress in the existing install log). Voice names now include gender and accent, e.g. "Alan (male, British)". + +Verified 2026-09-30: +- 240 unit tests pass. New tests check that installed voices aren't offered again, that gender and accent come through, that a standard install skips every extra voice, that a single-voice install fetches exactly that model and config (fetch stubbed, nothing downloaded), and that an unknown voice is rejected. +- The browser test checks that Alan is offered as "Alan (male, British)" and that Download posts `{ voice: "en_GB-alan-medium" }`. The request is intercepted in the page, so nothing is downloaded. +- 50 browser scenarios pass. +- Not run end-to-end: the real download through the API. It would have written into the developer's own `piper/` folder. The same Hugging Face URLs were downloaded and played with Piper while the voices were being checked. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 374578d..0dd9cda 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -237,7 +237,7 @@ Role: 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` +- `tts_install.js`: downloads the standalone Piper release for the OS and the standard voices (NST, Lisa, Ryan, Lessac) into `/piper/`, skipping anything present. `extra` catalog voices (Alma, Alan, Northern English, Cori, Alba, Joe, John, Norman) are only fetched one at a time via `POST /api/tts/install { voice }`; `tts.status()` lists them as `downloadable` for the Settings panel. The catalog is 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` diff --git a/package-lock.json b/package-lock.json index 1bcecc3..6ee89ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.28.0", + "version": "1.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.28.0", + "version": "1.29.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 58b2143..d2af95e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.28.0", + "version": "1.29.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 f74c899..638609c 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ 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 { installAll: installPiperAll, VOICES: PIPER_VOICES } = require("./server/tts_install"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; @@ -220,13 +220,16 @@ app.post("/api/tts/speech", async (req, res) => { } }); -// 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) => { +// One-time download of Piper + Swedish/English voices, or of one extra catalog voice +// ({ voice }), run in the background; progress is polled through /api/tts/status. Needs +// internet only while it runs. +const ttsInstall = { running: false, log: [], error: "", voice: "" }; +app.post("/api/tts/install", (req, res) => { + const voice = String(req.body?.voice || ""); + if (voice && !PIPER_VOICES.some((item) => item.name === voice)) return res.status(400).json({ error: "Unknown voice" }); 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) }) + Object.assign(ttsInstall, { running: true, log: [], error: "", voice }); + installPiperAll({ projectDir: __dirname, voice, 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; }); } diff --git a/server/tts.js b/server/tts.js index 7df60cf..8f5a3fe 100644 --- a/server/tts.js +++ b/server/tts.js @@ -36,7 +36,7 @@ function listVoices(voiceDirs) { entries.filter((entry) => entry.endsWith(".onnx") && entries.includes(`${entry}.json`)).forEach((entry) => { const name = entry.slice(0, -".onnx".length); 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) }); + if (!voices.has(name)) voices.set(name, { name, file: path.join(dir, entry), language: name.split(/[_-]/)[0].toLowerCase(), gender: known?.gender || "", accent: known?.accent || "", label: known?.label || name, isDefault: Boolean(known?.default) }); }); }); return [...voices.values()].sort((a, b) => a.name.localeCompare(b.name)); @@ -64,7 +64,11 @@ 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, gender, label, isDefault }) => ({ name, language, gender, label, isDefault })), voiceDirs, cachedLines }; + const installed = new Set(voices.map((voice) => voice.name)); + // Catalog voices not installed yet, offered for download in Settings. + const available = VOICE_CATALOG.filter((voice) => !installed.has(voice.name)) + .map(({ name, language, gender, accent = "", label }) => ({ name, language, gender, accent, label })); + return { available: Boolean(binary && voices.length), binary, voices: voices.map(({ name, language, gender, accent, label, isDefault }) => ({ name, language, gender, accent, label, isDefault })), downloadable: available, voiceDirs, cachedLines }; } // The voice name and speed are part of the key, so changing either never replays a stale line. diff --git a/server/tts_install.js b/server/tts_install.js index ebe645f..b07b697 100644 --- a/server/tts_install.js +++ b/server/tts_install.js @@ -16,14 +16,25 @@ 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. +// One male and one female voice per language are installed by default; `extra` voices +// (~63 MB each) are only downloaded when picked under Settings -> Audio. Genders were +// checked by measuring each voice's median pitch, not assumed from the name (nst 81 Hz, +// ryan 152, alan 103, northern_english_male 110, joe 98, john 117, norman 103; lisa 184, +// lessac 208, alma 185, alba 199, cori 202). `default` is the voice used until someone picks +// another in Settings. `accent` is shown next to the name. const VOICES = [ { 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" }, + { name: "sv_SE-alma-medium", path: "sv/sv_SE/alma/medium", language: "sv", gender: "female", label: "Alma", extra: true }, + { name: "en_US-ryan-medium", path: "en/en_US/ryan/medium", language: "en", gender: "male", label: "Ryan", accent: "us", default: true }, + { name: "en_US-lessac-medium", path: "en/en_US/lessac/medium", language: "en", gender: "female", label: "Lessac", accent: "us" }, + { name: "en_GB-alan-medium", path: "en/en_GB/alan/medium", language: "en", gender: "male", label: "Alan", accent: "gb", extra: true }, + { name: "en_GB-northern_english_male-medium", path: "en/en_GB/northern_english_male/medium", language: "en", gender: "male", label: "Northern English", accent: "gb_north", extra: true }, + { name: "en_GB-cori-medium", path: "en/en_GB/cori/medium", language: "en", gender: "female", label: "Cori", accent: "gb", extra: true }, + { name: "en_GB-alba-medium", path: "en/en_GB/alba/medium", language: "en", gender: "female", label: "Alba", accent: "scottish", extra: true }, + { name: "en_US-joe-medium", path: "en/en_US/joe/medium", language: "en", gender: "male", label: "Joe", accent: "us", extra: true }, + { name: "en_US-john-medium", path: "en/en_US/john/medium", language: "en", gender: "male", label: "John", accent: "us", extra: true }, + { name: "en_US-norman-medium", path: "en/en_US/norman/medium", language: "en", gender: "male", label: "Norman", accent: "us", extra: true }, ]; async function download(url, target) { @@ -47,9 +58,11 @@ async function installPiperBinary({ projectDir, log }) { if (result.status !== 0) throw new Error(`Unpacking failed: ${result.stderr || result.error?.message || "tar error"}`); } -async function installVoices({ voicesDir, log }) { +// Installs the standard voices, or exactly the named catalog voices (`names`). +async function installVoices({ voicesDir, log, names = null }) { fs.mkdirSync(voicesDir, { recursive: true }); - for (const voice of VOICES) { + const wanted = names ? VOICES.filter((voice) => names.includes(voice.name)) : VOICES.filter((voice) => !voice.extra); + for (const voice of wanted) { for (const file of [`${voice.name}.onnx.json`, `${voice.name}.onnx`]) { const target = path.join(voicesDir, file); if (fs.existsSync(target)) continue; @@ -60,10 +73,12 @@ async function installVoices({ voicesDir, log }) { } // Skips anything already present, so re-running is cheap and safe. -async function installAll({ projectDir, hasBinary, log = () => {} }) { +// `voice` set: install Piper if missing plus only that extra voice. +async function installAll({ projectDir, hasBinary, log = () => {}, voice = "" }) { + if (voice && !VOICES.some((item) => item.name === voice)) throw new Error(`Unknown voice: ${voice}`); if (!hasBinary) await installPiperBinary({ projectDir, log }); else log("Piper already installed."); - await installVoices({ voicesDir: path.join(projectDir, "piper", "voices"), log }); + await installVoices({ voicesDir: path.join(projectDir, "piper", "voices"), log, names: voice ? [voice] : null }); log("Done."); } diff --git a/src/app.js b/src/app.js index 92f3486..f6c28d6 100644 --- a/src/app.js +++ b/src/app.js @@ -1260,6 +1260,14 @@ const TRANSLATIONS = { "tts.voice_auto": "Automatisk", "tts.gender_male": "man", "tts.gender_female": "kvinna", + "tts.accent_us": "amerikansk", + "tts.accent_gb": "brittisk", + "tts.accent_gb_north": "nordengelsk", + "tts.accent_scottish": "skotsk", + "tts.more_voices": "Fler röster", + "tts.more_voices_hint": "Hämtas en i taget (ca 63 MB var, internet behövs bara under nedladdningen). Välj sedan rösten ovan och testa.", + "tts.download": "Hämta", + "tts.downloading": "Hämtar...", "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.", @@ -2902,6 +2910,14 @@ const TRANSLATIONS = { "tts.voice_auto": "Automatic", "tts.gender_male": "male", "tts.gender_female": "female", + "tts.accent_us": "American", + "tts.accent_gb": "British", + "tts.accent_gb_north": "Northern English", + "tts.accent_scottish": "Scottish", + "tts.more_voices": "More voices", + "tts.more_voices_hint": "Downloaded one at a time (about 63 MB each, internet only needed while downloading). Then pick the voice above and test it.", + "tts.download": "Download", + "tts.downloading": "Downloading...", "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.", diff --git a/src/styles.css b/src/styles.css index d07f9cc..f3f4971 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4439,6 +4439,20 @@ select:focus { flex: 1; } +.tts-download-list { + display: grid; + gap: 6px; +} + +.tts-download-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--line, rgba(128, 128, 128, 0.2)); +} + .track-layout-bar { display: flex; flex-wrap: wrap; diff --git a/src/tts_settings_view.js b/src/tts_settings_view.js index f94ab82..99d26fb 100644 --- a/src/tts_settings_view.js +++ b/src/tts_settings_view.js @@ -39,7 +39,11 @@ export async function mountTtsSettings(container, deps) { 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 voiceLabel = (voice) => { + const details = [voice.gender ? t(voice.gender === "male" ? "tts.gender_male" : "tts.gender_female") : "", voice.accent ? t(`tts.accent_${voice.accent}`) : ""].filter(Boolean); + return details.length ? `${voice.label} (${details.join(", ")})` : voice.label; + }; + const downloadable = status?.downloadable || []; const voiceSelect = (language) => { const chosen = state.settings[voiceSettingKey(language)] || ""; const automatic = voicesFor(language).find((voice) => voice.isDefault) || voicesFor(language)[0]; @@ -82,6 +86,16 @@ export async function mountTtsSettings(container, deps) { ${voiceSelect("en")} + ${downloadable.length ? ` +

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

+

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

+
+ ${downloadable.map((voice) => ` +
+ ${voice.language === "sv" ? "Svenska" : "English"} · ${escapeHtml(voiceLabel(voice))} + +
`).join("")} +
` : ""} ${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 }))}

` : ""}`; @@ -93,6 +107,12 @@ export async function mountTtsSettings(container, deps) { await fetch("/api/tts/install", { method: "POST" }); remount(); }); + container.querySelectorAll("[data-tts-download]").forEach((button) => { + button.addEventListener("click", async () => { + await fetch("/api/tts/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voice: button.dataset.ttsDownload }) }); + 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(); diff --git a/tests/browser_tts_settings.js b/tests/browser_tts_settings.js index 350d9a6..8e4b33c 100644 --- a/tests/browser_tts_settings.js +++ b/tests/browser_tts_settings.js @@ -24,6 +24,27 @@ export async function runTtsSettingsSmoke({ evaluate, navigate, savedState, wait 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"); + // Extra catalog voices are offered with gender and accent, and "Download" asks the server + // for exactly that voice. The request is intercepted in the page, so nothing is downloaded. + const button = `document.querySelector('[data-tts-download="en_GB-alan-medium"]')`; + await waitFor(async () => Boolean(await evaluate(button)), "British voice offered for download"); + assert.match(await evaluate(`${button}.closest(".tts-download-row").textContent`), /Alan \((male|man), (British|brittisk)\)/); + const requested = await evaluate(`(async () => { + const realFetch = window.fetch; + let body = null; + window.fetch = async (url, options) => { + if (String(url).includes("/api/tts/install")) { body = options?.body; return new Response("{}", { status: 202 }); } + return realFetch(url, options); + }; + try { + ${button}.click(); + for (let i = 0; i < 50 && !body; i += 1) await new Promise((resolve) => setTimeout(resolve, 20)); + return body; + } finally { window.fetch = realFetch; } + })()`); + assert.deepEqual(JSON.parse(requested), { voice: "en_GB-alan-medium" }); + console.log("PASS extra voices are offered with gender and accent, and Download requests just that voice"); + 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/tts.test.js b/tests/tts.test.js index bf78648..a266494 100644 --- a/tests/tts.test.js +++ b/tests/tts.test.js @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; const require = createRequire(import.meta.url); const { createTts, listVoices, pickVoice, resolvePiperBinary } = require("../server/tts.js"); +const { installVoices, installAll, VOICES } = require("../server/tts_install.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. @@ -64,6 +65,43 @@ test("voices need a matching .onnx.json, carry catalog gender labels, and the ch } finally { env.cleanup(); } }); +test("status offers the catalog voices that aren't installed yet, with gender and accent", () => { + const env = setup(); + try { + const status = createTts({ dataDir: env.root, projectDir: env.root, voiceDirs: [env.voices] }).status(); + const offered = Object.fromEntries(status.downloadable.map((voice) => [voice.name, voice])); + assert.equal(offered["sv_SE-nst-medium"], undefined, "installed voices are not offered again"); + assert.deepEqual(offered["sv_SE-alma-medium"], { name: "sv_SE-alma-medium", language: "sv", gender: "female", accent: "", label: "Alma" }); + assert.equal(offered["en_GB-alan-medium"].accent, "gb"); + assert.equal(offered["en_GB-alan-medium"].gender, "male"); + assert.equal(status.voices.find((voice) => voice.name === "en_US-ryan-medium").accent, "us"); + } finally { env.cleanup(); } +}); + +test("a standard install skips the extra voices; picking one downloads only that voice", async () => { + const env = setup(); + const originalFetch = globalThis.fetch; + const urls = []; + globalThis.fetch = async (url) => { urls.push(String(url)); return new Response("model"); }; + try { + const target = join(env.root, "fresh-voices"); + await installVoices({ voicesDir: target, log: () => {} }); + const standard = VOICES.filter((voice) => !voice.extra).map((voice) => voice.name); + assert.deepEqual(standard, ["sv_SE-nst-medium", "sv_SE-lisa-medium", "en_US-ryan-medium", "en_US-lessac-medium"]); + assert.equal(urls.length, standard.length * 2, "model + config per standard voice"); + assert.ok(!urls.some((url) => url.includes("en_GB")), "no extra voice in a standard install"); + + urls.length = 0; + await installVoices({ voicesDir: target, log: () => {}, names: ["en_GB-alan-medium"] }); + assert.deepEqual(urls.map((url) => url.split("/main/")[1]), ["en/en_GB/alan/medium/en_GB-alan-medium.onnx.json", "en/en_GB/alan/medium/en_GB-alan-medium.onnx"]); + assert.ok(readdirSync(target).includes("en_GB-alan-medium.onnx")); + await assert.rejects(installAll({ projectDir: env.root, hasBinary: true, voice: "made_up-voice" }), /Unknown voice/); + } finally { + globalThis.fetch = originalFetch; + env.cleanup(); + } +}); + test("the Piper copy installed into the project is preferred over one on PATH", () => { const env = setup(); try {