tests/speakVoices.test.ts
Rama main · réplica de lectura del repositorio original.
// The voices folder's scan (server/speakVoices.ts) over a fixture tree shaped
// like the folders in the wild: a loose single-speaker Piper voice, the
// rhasspy/piper-voices catalogue's nested layout with a multi-speaker model,
// a config without its model, a model without its config, a config that is
// not JSON, a voice in a language Read aloud does not detect, a Kokoro pack
// beside its model and one without, a symlink loop — and the folder's own
// refusals (relative, inside the vault, missing, a file).
import assert from "node:assert/strict";
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { zipSync } from "../shared/zip.ts";
import { initVault } from "../server/vault.ts";
import { scanVoicesDir } from "../server/speakVoices.ts";
import { makeDir, makeVault, removeVault } from "./helpers/vault.ts";
const config = (code: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({
audio: { sample_rate: 22050, quality: "medium" },
espeak: { voice: code.split("_")[0] },
phoneme_id_map: { _: [0] },
num_speakers: 1,
speaker_id_map: {},
language: { code, family: code.split("_")[0] },
...extra,
});
const root = makeDir();
const vault = makeVault({ "Note.md": "# Note\n" });
function put(rel: string, content: string | Uint8Array = "onnx"): void {
const abs = path.join(root, rel);
mkdirSync(path.dirname(abs), { recursive: true });
writeFileSync(abs, content);
}
before(() => {
initVault(vault);
// Loose, single speaker — where Read Text or a hand install leaves one.
put("fr_FR-siwis-medium.onnx");
put("fr_FR-siwis-medium.onnx.json", config("fr_FR", { dataset: "siwis" }));
// The catalogue's layout, four deep, with two speakers in one model.
put("fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx");
put("fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx.json", config("fr_FR", { num_speakers: 2, speaker_id_map: { jessica: 0, pierre: 1 } }));
put("ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx");
put("ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx.json", config("ar_JO"));
// Not voices, each for its own reason.
put("half/en_US-ryan-high.onnx"); // no config beside it
put("half/en_US-amy-low.onnx.json", config("en_US")); // no model beside it
put("broken/es_ES-x-low.onnx");
put("broken/es_ES-x-low.onnx.json", "{ not json");
put("de/de_DE-thorsten-medium.onnx");
put("de/de_DE-thorsten-medium.onnx.json", config("de_DE"));
// A Kokoro pack beside its model, and one alone.
const npz = zipSync(["af_heart", "ff_siwis", "jf_alpha", "zf_xiaobei"].map((v) => ({ name: `${v}.npy`, data: new Uint8Array(8) })));
put("kokoro/kokoro-v1.0.onnx");
put("kokoro/kokoro-v1.0.int8.onnx");
put("kokoro/voices-v1.0.bin", npz);
put("lonely/voices-v1.0.bin", npz);
// Hidden folders are stepped around; a link back to the top ends.
put(".cache/en_US-lessac-medium.onnx");
put(".cache/en_US-lessac-medium.onnx.json", config("en_US"));
symlinkSync(root, path.join(root, "fr", "loop"));
});
after(() => {
removeVault(root);
removeVault(vault);
});
describe("the voices folder's scan", () => {
it("finds every voice, one per speaker, with its language, and the model it loads", async () => {
const scan = await scanVoicesDir(root, vault);
assert.equal(scan.problem, null);
assert.equal(scan.truncated, false);
const ids = scan.voices.map((v) => `${v.lang} ${v.id} ${v.name}`);
assert.deepEqual(ids, [
"ar own:ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx Kareem",
"en own:kokoro/voices-v1.0.bin#af_heart Heart",
"fr own:fr_FR-siwis-medium.onnx Siwis",
"fr own:kokoro/voices-v1.0.bin#ff_siwis Siwis",
"fr own:fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx#jessica Upmc · Jessica",
"fr own:fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx#pierre Upmc · Pierre",
"ja own:kokoro/voices-v1.0.bin#jf_alpha Alpha",
]);
const pierre = scan.voices.find((v) => v.speaker === "pierre")!;
assert.equal(pierre.model, path.join(root, "fr/fr_FR/upmc/medium/fr_FR-upmc-medium.onnx"));
assert.equal(pierre.config, `${pierre.model}.json`);
assert.equal(pierre.speakerId, 1);
assert.equal(pierre.quality, "medium");
const heart = scan.voices.find((v) => v.speaker === "af_heart")!;
assert.equal(heart.kind, "kokoro");
assert.equal(heart.model, path.join(root, "kokoro/kokoro-v1.0.onnx"), "the full-precision model, not the int8 one");
assert.equal(heart.pack, path.join(root, "kokoro/voices-v1.0.bin"));
});
it("lists what it skipped and why, and nothing stops it", async () => {
const scan = await scanVoicesDir(root, vault);
const skipped = scan.skipped.map((s) => `${s.reason} ${s.file}${s.lang ? ` ${s.lang}` : ""}`).sort();
assert.deepEqual(skipped, [
"badJson broken/es_ES-x-low.onnx.json",
"noJson half/en_US-ryan-high.onnx",
"noKokoroModel lonely/voices-v1.0.bin",
"noModel half/en_US-amy-low.onnx.json",
"otherLanguage de/de_DE-thorsten-medium.onnx de",
"otherLanguage kokoro/voices-v1.0.bin zh",
]);
});
it("steps around the vault when the folder contains it", async () => {
const outer = makeDir();
const inner = path.join(outer, "vault");
mkdirSync(inner);
writeFileSync(path.join(inner, "en_US-x-low.onnx"), "onnx");
writeFileSync(path.join(inner, "en_US-x-low.onnx.json"), config("en_US"));
writeFileSync(path.join(outer, "fr_FR-y-low.onnx"), "onnx");
writeFileSync(path.join(outer, "fr_FR-y-low.onnx.json"), config("fr_FR"));
try {
const scan = await scanVoicesDir(outer, inner);
assert.deepEqual(scan.voices.map((v) => v.id), ["own:fr_FR-y-low.onnx"]);
} finally {
removeVault(outer);
}
});
it("refuses a folder it cannot be, with the reason", async () => {
assert.equal((await scanVoicesDir("relative/voices", vault)).problem, "relative");
assert.equal((await scanVoicesDir(path.join(vault, "voices"), vault)).problem, "inVault");
assert.equal((await scanVoicesDir(vault, vault)).problem, "inVault");
assert.equal((await scanVoicesDir(path.join(root, "nowhere"), vault)).problem, "missing");
assert.equal((await scanVoicesDir(path.join(root, "fr_FR-siwis-medium.onnx"), vault)).problem, "notDir");
});
});