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:
larssand
2026-09-27 20:29:59 +02:00
parent e8a2799a92
commit 196437d25b
17 changed files with 167 additions and 38 deletions

View File

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

View File

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