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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user