tests/speechVoices.test.ts
Rama main · réplica de lectura del repositorio original.
// Your own voices, the pure half (shared/speechVoices.ts): what a Piper
// config and a Kokoro pack say, the ids, the voices folder's path refusals,
// the merge of built-in and found voices into one picker, which voice
// speaks, and the external speaker's command template.
import assert from "node:assert/strict";
import path from "node:path";
import { describe, it } from "node:test";
import {
countByLang,
engineNeededWith,
externalArgv,
externalCommandProblem,
isOwnVoiceId,
kokoroEntries,
kokoroVoiceLang,
pickerLangs,
piperConfigLang,
piperEntries,
piperFileParts,
piperSpeakerId,
resolveVoice,
skippedByReason,
splitCommand,
voiceChoices,
voicesDirRefusal,
type OwnVoice,
} from "../shared/speechVoices.ts";
const piperConfig = (extra: Record<string, unknown> = {}) => ({
audio: { sample_rate: 22050, quality: "medium" },
espeak: { voice: "fr" },
phoneme_id_map: { a: [1] },
num_speakers: 1,
speaker_id_map: {},
language: { code: "fr_FR", family: "fr", region: "FR" },
dataset: "siwis",
...extra,
});
describe("a Piper voice file", () => {
it("reads the catalogue's file name: locale, name, quality", () => {
assert.deepEqual(piperFileParts("fr_FR-upmc-medium"), { locale: "fr_FR", name: "upmc", quality: "medium" });
assert.deepEqual(piperFileParts("en_US-lessac-x_low"), { locale: "en_US", name: "lessac", quality: "x_low" });
assert.deepEqual(piperFileParts("ar_JO-kareem-medium"), { locale: "ar_JO", name: "kareem", quality: "medium" });
assert.deepEqual(piperFileParts("my-voice"), { locale: null, name: "my-voice", quality: null });
});
it("takes the language from the config first, then the file name", () => {
assert.deepEqual(piperConfigLang(piperConfig(), null), { lang: "fr", locale: "fr_FR" });
assert.deepEqual(piperConfigLang({ language: { family: "ar" } }, null), { lang: "ar", locale: "ar" });
assert.deepEqual(piperConfigLang({ espeak: { voice: "en-us" } }, null), { lang: "en", locale: "en_us" });
assert.deepEqual(piperConfigLang({}, "es_ES"), { lang: "es", locale: "es_ES" });
assert.equal(piperConfigLang({}, null), null);
});
it("is one voice, named, in its language", () => {
const got = piperEntries("fr_FR-siwis-medium.onnx", piperConfig());
assert.ok("voices" in got);
assert.deepEqual(got.voices, [
{ id: "own:fr_FR-siwis-medium.onnx", name: "Siwis", lang: "fr", locale: "fr_FR", quality: "medium", speaker: null, kind: "piper" },
]);
});
it("is one voice PER SPEAKER when the model has several (fr_FR-upmc: jessica and pierre)", () => {
const config = piperConfig({ num_speakers: 2, speaker_id_map: { pierre: 1, jessica: 0 }, dataset: "upmc" });
const got = piperEntries("fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx", config);
assert.ok("voices" in got);
assert.deepEqual(
got.voices.map((v) => [v.id, v.name, v.speaker]),
[
["own:fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx#jessica", "Upmc · Jessica", "jessica"],
["own:fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx#pierre", "Upmc · Pierre", "pierre"],
],
);
assert.equal(piperSpeakerId(config, "pierre"), 1);
assert.equal(piperSpeakerId(config, "nobody"), null);
assert.equal(piperSpeakerId(config, null), null);
});
it("names a file that does not follow the convention by its dataset", () => {
const got = piperEntries("mine/voice.onnx", piperConfig({ dataset: "grand_pere" }));
assert.ok("voices" in got);
assert.equal(got.voices[0].name, "Grand Pere");
});
it("says why a file is not a voice", () => {
assert.deepEqual(piperEntries("x.onnx", "not an object"), { skip: "badJson" });
assert.deepEqual(piperEntries("x.onnx", { audio: {} }), { skip: "badJson" }, "no phoneme table: not a Piper config");
assert.deepEqual(piperEntries("x.onnx", { phoneme_id_map: {} }), { skip: "noLanguage" });
assert.deepEqual(piperEntries("de_DE-thorsten-medium.onnx", piperConfig({ language: { code: "de_DE" } })), {
skip: "otherLanguage",
lang: "de",
});
});
});
describe("a Kokoro pack", () => {
it("reads a voice's language from its first letter", () => {
assert.equal(kokoroVoiceLang("af_heart"), "en");
assert.equal(kokoroVoiceLang("bm_george"), "en");
assert.equal(kokoroVoiceLang("ff_siwis"), "fr");
assert.equal(kokoroVoiceLang("jf_alpha"), "ja");
assert.equal(kokoroVoiceLang("zf_xiaobei"), "zh");
assert.equal(kokoroVoiceLang("weird"), null);
});
it("is one voice per npz member in a language Read aloud speaks; the rest are counted", () => {
const { voices, other } = kokoroEntries("kokoro/voices-v1.0.bin", ["af_heart.npy", "bm_george.npy", "ff_siwis.npy", "zf_xiaobei.npy", "hf_alpha.npy", "README"]);
assert.deepEqual(
voices.map((v) => [v.id, v.name, v.lang, v.locale, v.kind]),
[
["own:kokoro/voices-v1.0.bin#af_heart", "Heart", "en", "en_US", "kokoro"],
["own:kokoro/voices-v1.0.bin#bm_george", "George (British)", "en", "en_GB", "kokoro"],
["own:kokoro/voices-v1.0.bin#ff_siwis", "Siwis", "fr", "fr", "kokoro"],
],
);
assert.deepEqual([...other], [["zh", 1], ["hi", 1]]);
});
});
describe("ids", () => {
it("an own id is a relative path, never one that climbs", () => {
assert.equal(isOwnVoiceId("own:fr/x.onnx#pierre"), true);
assert.equal(isOwnVoiceId("own:x.onnx"), true);
assert.equal(isOwnVoiceId("own:"), false);
assert.equal(isOwnVoiceId("own:../x.onnx"), false);
assert.equal(isOwnVoiceId("own:a//x.onnx"), false);
assert.equal(isOwnVoiceId("fr_FR-siwis-medium"), false);
assert.equal(isOwnVoiceId(42), false);
});
});
describe("the voices folder's path", () => {
const posixAbs = (p: string): boolean => p.startsWith("/");
it("must be absolute", () => {
assert.equal(voicesDirRefusal("piper-voices", "/home/me/vault", posixAbs), "relative");
assert.equal(voicesDirRefusal("~/piper", "/home/me/vault", posixAbs), "relative");
});
it("must be outside the vault — the vault itself and anything in it are refused", () => {
assert.equal(voicesDirRefusal("/home/me/vault", "/home/me/vault", posixAbs), "inVault");
assert.equal(voicesDirRefusal("/home/me/vault/voices", "/home/me/vault", posixAbs), "inVault");
assert.equal(voicesDirRefusal("/home/me/vault/", "/home/me/vault", posixAbs), "inVault");
});
it("a neighbour whose name starts like the vault's is not inside it; a parent is allowed", () => {
assert.equal(voicesDirRefusal("/home/me/vault-voices", "/home/me/vault", posixAbs), null);
assert.equal(voicesDirRefusal("/home/me/.local/share/piper-voices", "/home/me/vault", posixAbs), null);
assert.equal(voicesDirRefusal("/home/me", "/home/me/vault", posixAbs), null);
});
it("on Windows, case does not hide the vault", () => {
assert.equal(voicesDirRefusal("C:\\Users\\Me\\VAULT\\voices", "C:\\Users\\Me\\vault", path.win32.isAbsolute, "\\"), "inVault");
assert.equal(voicesDirRefusal("voices", "C:\\Users\\Me\\vault", path.win32.isAbsolute, "\\"), "relative");
});
});
// ── The picker, and which voice speaks ─────────────────────────────────────
const own: OwnVoice[] = [
{ id: "own:fr_FR-upmc-medium.onnx#jessica", name: "Upmc · Jessica", lang: "fr", locale: "fr_FR", quality: "medium", speaker: "jessica", kind: "piper" },
{ id: "own:fr_FR-upmc-medium.onnx#pierre", name: "Upmc · Pierre", lang: "fr", locale: "fr_FR", quality: "medium", speaker: "pierre", kind: "piper" },
{ id: "own:es_ES-davefx-medium.onnx", name: "Davefx", lang: "es", locale: "es_ES", quality: "medium", speaker: null, kind: "piper" },
{ id: "own:k/voices.bin#af_nicole", name: "Nicole", lang: "en", locale: "en_US", quality: null, speaker: "af_nicole", kind: "kokoro" },
];
const light = new Set(["light"] as const);
const both = new Set(["light", "natural"] as const);
const none = new Set<"light" | "natural">();
describe("the picker: built-in and found, per language", () => {
it("offers the built-in engine's voices, then the folder's", () => {
const fr = voiceChoices("fr", "light", light, own);
assert.equal(fr.engine, "light");
assert.deepEqual(fr.builtin.map((v) => v.id), ["fr_FR-siwis-medium", "fr_FR-upmc-medium#pierre", "fr_FR-upmc-medium#jessica"]);
assert.deepEqual(fr.own.map((v) => v.id), ["own:fr_FR-upmc-medium.onnx#jessica", "own:fr_FR-upmc-medium.onnx#pierre"]);
});
it("a language no built-in voice of the choice speaks still gets its found voices", () => {
const es = voiceChoices("es", "light", light, own);
assert.equal(es.engine, "natural", "Spanish's built-in voices are Kokoro's, not installed");
assert.deepEqual(es.own.map((v) => v.id), ["own:es_ES-davefx-medium.onnx"]);
});
it("draws a picker for a language with a choice to make", () => {
// fr: found voices; es: a found voice; en under Light: one built-in and a
// found Kokoro voice; ar: one built-in, nothing found — no picker.
assert.deepEqual(pickerLangs(["en", "fr", "ar", "es", "ja"], "light", light, own), ["en", "fr", "es"]);
// Natural's English has five voices: a picker with nothing found.
assert.deepEqual(pickerLangs(["en", "ar"], "natural", both, []), ["en"]);
});
});
describe("which voice speaks", () => {
it("no pick: the built-in engine's first voice, as before", () => {
assert.deepEqual(resolveVoice("fr", "light", undefined, light, own), { engine: "light", voice: "fr_FR-siwis-medium", own: null });
});
it("a found voice picked is the one that speaks, by the engine whose runtime it needs", () => {
const got = resolveVoice("fr", "light", "own:fr_FR-upmc-medium.onnx#pierre", light, own);
assert.equal(got?.engine, "light");
assert.equal(got?.voice, "own:fr_FR-upmc-medium.onnx#pierre");
assert.equal(got?.own?.speaker, "pierre");
});
it("a found Kokoro voice needs Natural's runtime; without it the built-in answers", () => {
assert.equal(resolveVoice("en", "light", "own:k/voices.bin#af_nicole", both, own)?.voice, "own:k/voices.bin#af_nicole");
assert.equal(resolveVoice("en", "light", "own:k/voices.bin#af_nicole", light, own)?.voice, "en_US-lessac-medium");
});
it("a pick that is no longer in the folder falls back to the built-in voice", () => {
assert.equal(resolveVoice("fr", "light", "own:gone.onnx", light, own)?.voice, "fr_FR-siwis-medium");
});
it("a pick in another language is not taken", () => {
assert.equal(resolveVoice("ar", "light", "own:fr_FR-upmc-medium.onnx#pierre", light, own)?.voice, "ar_JO-kareem-medium");
});
it("a language only a found voice speaks is spoken by it", () => {
assert.deepEqual(resolveVoice("es", "light", undefined, light, own), {
engine: "light",
voice: "own:es_ES-davefx-medium.onnx",
own: own[2],
});
});
it("a Light voice picked under Natural is spoken by Light — the cross-engine choice", () => {
const pierre = "fr_FR-upmc-medium#pierre";
assert.deepEqual(resolveVoice("fr", "natural", pierre, both, []), { engine: "light", voice: pierre, own: null });
// …while Natural still speaks every language whose pick is its own.
assert.deepEqual(resolveVoice("fr", "natural", "ff_siwis", both, []), { engine: "natural", voice: "ff_siwis", own: null });
assert.deepEqual(resolveVoice("fr", "natural", undefined, both, []), { engine: "natural", voice: "ff_siwis", own: null });
// Light not installed: the pick cannot be honoured, Natural's voice speaks.
assert.deepEqual(resolveVoice("fr", "natural", pierre, new Set(["natural"] as const), []), { engine: "natural", voice: "ff_siwis", own: null });
// A Natural voice picked under Light, the other way round.
assert.deepEqual(resolveVoice("en", "light", "am_michael", both, []), { engine: "natural", voice: "am_michael", own: null });
});
it("an on-choice voice not downloaded yet: the language's engine and first voice speak meanwhile", () => {
const pierre = "fr_FR-upmc-medium#pierre";
const notYet = { ready: (id: string) => id !== pierre };
assert.deepEqual(resolveVoice("fr", "natural", pierre, both, [], notYet), { engine: "natural", voice: "ff_siwis", own: null });
assert.deepEqual(resolveVoice("fr", "light", pierre, both, [], notYet), { engine: "light", voice: "fr_FR-siwis-medium", own: null });
});
it("a language the chosen engine's self-test refused is Light's, never the English rules'", () => {
const refused = { natural: ["fr"] as const };
assert.deepEqual(resolveVoice("fr", "natural", undefined, both, [], { refused }), { engine: "light", voice: "fr_FR-siwis-medium", own: null });
assert.deepEqual(resolveVoice("fr", "natural", "ff_siwis", both, [], { refused }), { engine: "light", voice: "fr_FR-siwis-medium", own: null });
assert.equal(resolveVoice("fr", "natural", undefined, new Set(["natural"] as const), [], { refused }), null);
assert.equal(engineNeededWith("fr", "natural", [], refused), "light");
});
it("the picker offers the other installed engine's voices for the language, and not a refused engine's", () => {
const fr = voiceChoices("fr", "natural", both, []);
assert.equal(fr.engine, "natural");
assert.deepEqual(fr.builtin.map((v) => `${v.name} (${v.gender})`), ["Siwis (f)"]);
assert.equal(fr.also?.engine, "light");
assert.deepEqual(fr.also?.voices.map((v) => `${v.name} (${v.gender})`), ["Siwis (f)", "Pierre (m)", "Jessica (f)"]);
assert.equal(voiceChoices("fr", "natural", new Set(["natural"] as const), []).also, null);
const refused = voiceChoices("fr", "natural", both, [], { refused: { natural: ["fr"] } });
assert.equal(refused.engine, "light");
assert.equal(refused.also, null);
// French now has a choice to make under either engine.
assert.deepEqual(pickerLangs(["fr"], "natural", both, []), ["fr"]);
});
it("nothing installed: nothing speaks, and the runtime to install is named", () => {
assert.equal(resolveVoice("fr", "light", "own:fr_FR-upmc-medium.onnx#pierre", none, own), null);
assert.equal(engineNeededWith("fr", "light", own), "light");
assert.equal(engineNeededWith("es", "light", own), "light", "the found Piper voice needs Light, not Kokoro");
assert.equal(engineNeededWith("es", "light", []), "natural");
assert.equal(engineNeededWith("en", "light", own.slice(3)), "natural");
});
it("counts per language and groups the skipped by why", () => {
assert.deepEqual(countByLang(own), [
{ lang: "fr", count: 2 },
{ lang: "en", count: 1 },
{ lang: "es", count: 1 },
]);
assert.deepEqual(
skippedByReason([
{ file: "a.onnx", reason: "noJson" },
{ file: "b.onnx.json", reason: "noModel" },
{ file: "c.onnx", reason: "noJson" },
]),
[
{ reason: "noJson", count: 2, files: ["a.onnx", "c.onnx"] },
{ reason: "noModel", count: 1, files: ["b.onnx.json"] },
],
);
});
});
describe("the external speaker's command", () => {
it("splits like a shell line, quotes and escapes, and nothing more", () => {
assert.deepEqual(splitCommand("piper --model /v/fr.onnx --output_file {out}"), ["piper", "--model", "/v/fr.onnx", "--output_file", "{out}"]);
assert.deepEqual(splitCommand(`python3 "/home/me/My Voices/speak.py" --lang '{lang}' --out {out}`), [
"python3",
"/home/me/My Voices/speak.py",
"--lang",
"{lang}",
"--out",
"{out}",
]);
assert.deepEqual(splitCommand("a\\ b \"c \\\"d\\\"\" ''"), ["a b", 'c "d"', ""]);
assert.deepEqual(splitCommand("echo $HOME; rm -rf / | cat"), ["echo", "$HOME;", "rm", "-rf", "/", "|", "cat"], "no shell: these are words");
assert.equal(splitCommand('piper "unclosed'), null);
});
it("must name {out}, have closed quotes, and fit", () => {
assert.equal(externalCommandProblem("piper --output_file {out}"), null);
assert.equal(externalCommandProblem("python3 s.py --out={out}.wav"), null);
assert.equal(externalCommandProblem(""), "empty");
assert.equal(externalCommandProblem("piper --model x.onnx"), "noOut");
assert.equal(externalCommandProblem("{out}"), "noOut", "the program itself cannot be the file");
assert.equal(externalCommandProblem('piper "{out}'), "quotes");
assert.equal(externalCommandProblem(`p ${"x".repeat(2001)} {out}`), "tooLong");
});
it("substitutes {lang} and {out} in every word", () => {
assert.deepEqual(externalArgv("speak --language {lang} --voice {lang}-best -o {out}", "fr", "/tmp/a.wav"), [
"speak",
"--language",
"fr",
"--voice",
"fr-best",
"-o",
"/tmp/a.wav",
]);
});
});