Files
Live_RC/tests/tts.test.js
larssand ebd0f74f40 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>
2026-09-30 21:58:08 +02:00

156 lines
8.7 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
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.
function fakeRun(calls, { fail = false, delayMs = 5 } = {}) {
let active = 0;
return (binary, args, _options, callback) => {
active += 1;
calls.push({ binary, args, maxParallel: active });
let text = "";
const stdin = { end: (value) => { text = value; } };
setTimeout(() => {
active -= 1;
if (fail) return callback(new Error("piper crashed"));
writeFileSync(args[args.indexOf("-f") + 1], `RIFF${text}`);
return callback(null);
}, delayMs);
return { stdin };
};
}
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-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`), "{}");
}
writeFileSync(join(voices, "orphan-medium.onnx"), ""); // no .json config -> not a usable voice
mkdirSync(join(root, "piper"));
const binary = join(root, "piper", process.platform === "win32" ? "piper.exe" : "piper");
writeFileSync(binary, "");
chmodSync(binary, 0o755);
return { root, voices, binary, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
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", "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(); }
});
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 {
assert.equal(resolvePiperBinary("", env.root), env.binary);
assert.equal(resolvePiperBinary(env.binary, "/nonexistent"), env.binary, "an explicit path wins");
} finally { env.cleanup(); }
});
test("each line is generated once, cached, deduplicated, and generated one at a time", async () => {
const env = setup();
const calls = [];
try {
const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls) });
const [a, b] = await Promise.all([
tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }),
tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 }),
]);
assert.equal(a, b, "whitespace-normalized duplicates share one file");
assert.equal(calls.length, 1, "concurrent identical requests only run Piper once");
await tts.speech({ text: "Heat ett startar.", language: "sv", rate: 1 });
assert.equal(calls.length, 1, "a cached line never runs Piper again");
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-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, 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(); }
});
test("missing voice or failing Piper reports clear errors, and pre-generation counts failures", async () => {
const env = setup();
const calls = [];
try {
const tts = createTts({ dataDir: join(env.root, "data"), projectDir: env.root, voiceDirs: [env.voices], run: fakeRun(calls, { fail: true }) });
await assert.rejects(tts.speech({ text: "Moi", language: "fi" }), (error) => error.code === "unavailable");
await assert.rejects(tts.speech({ text: " ", language: "sv" }), (error) => error.code === "bad_request");
await assert.rejects(tts.speech({ text: "Hej", language: "sv" }), /piper crashed/);
const result = await tts.pregenerate([{ text: "Hej", language: "sv" }, { text: "Hi", language: "en" }, { text: "", language: "sv" }]);
assert.deepEqual(result, { running: false, total: 2, done: 2, failed: 2 });
} finally { env.cleanup(); }
});