feat(audio): add downloadable extra Piper voices with accents

Adds Alma (sv), Alan, Northern English, Cori, Alba (British/Scottish)
and Joe, John, Norman (American) as optional voices downloaded one at
a time from Settings, with gender (measured pitch) and accent shown in
the voice lists. The standard install is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-30 21:58:08 +02:00
parent aaca5e8c63
commit ebd0f74f40
15 changed files with 186 additions and 25 deletions

View File

@@ -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.

View File

@@ -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.");
}