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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user