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

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

View File

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