Ctrl K
ComunidadGuías…

tests/speechVoices.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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",
    ]);
  });
});