Files
Live_RC/tests/tts.test.js
larssand 196437d25b 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>
2026-09-27 20:29:59 +02:00

118 lines
6.5 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");
// 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("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(); }
});