Ctrl K
ComunidadGuías…

tests/speech.test.ts

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

Archivo original ↗Preguntar a KiBot
// Read aloud: which language a run of text is spoken in, where a sentence
// ends (Latin, Arabic, Japanese and French punctuation), which engine answers
// a language, and what a note says when it is read whole. The engines
// themselves are faked in tests/speakRoutes.test.ts; what they sound like is
// docs/read-aloud.md's trial, measured by ear and by whisper, not here.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  closeSentence,
  detectSpeechLang,
  engineFor,
  engineNeeded,
  frontmatterLang,
  isVoiceOf,
  SENTENCE_MAX,
  speakEffective,
  speakFurigana,
  speakLangOf,
  speechTextOfNote,
  splitSentences,
  splitSentencesIn,
  passageSpeechLang,
  builtinVoice,
  isOnChoiceVoice,
  refusalsOf,
  SPEAK_ENGINES,
  SPEAK_VOICES,
  voiceFor,
  type SpeakEngineId,
} from "../shared/speech.ts";

describe("detectSpeechLang: script first", () => {
  it("Arabic letters are Arabic, pointed or not", () => {
    assert.equal(detectSpeechLang("الْمَكْتَبَةُ"), "ar");
    assert.equal(detectSpeechLang("المكتبة العامة"), "ar");
  });
  it("kana and kanji are Japanese, a lone kanji word included", () => {
    assert.equal(detectSpeechLang("図書館"), "ja");
    assert.equal(detectSpeechLang("ありがとう"), "ja");
    assert.equal(detectSpeechLang("東京タワー Tokyo"), "ja"); // a tie goes to Japanese
  });
  it("by count: an English sentence quoting one kanji stays English", () => {
    assert.equal(detectSpeechLang("The word 猫 means cat in Japanese."), "en");
    assert.equal(detectSpeechLang("The word كتاب means book."), "en");
  });
  it("script beats every hint", () => {
    assert.equal(detectSpeechLang("図書館", { noteLang: "fr" }), "ja");
    assert.equal(detectSpeechLang("كتاب", { noteLang: "en", siteLang: "en" }), "ar");
  });
});

describe("detectSpeechLang: Latin text", () => {
  it("a French sentence is French in an English note", () => {
    assert.equal(detectSpeechLang("Les enfants ont appris leurs leçons en une heure.", { noteLang: "en" }), "fr");
  });
  it("the note's own lang decides an ambiguous line", () => {
    assert.equal(detectSpeechLang("grenouille", { noteLang: "fr" }), "fr");
    assert.equal(detectSpeechLang("hola amigo", { noteLang: "es-MX" }), "es");
  });
  it("a lone word takes the language of the sentence it was selected from", () => {
    assert.equal(detectSpeechLang("grenouille"), "en");
    assert.equal(detectSpeechLang("grenouille", { context: "La grenouille est dans le jardin de la maison." }), "fr");
  });
  it("a lone accented word is French", () => {
    assert.equal(detectSpeechLang("élève"), "fr");
    assert.equal(detectSpeechLang("naïveté"), "fr");
  });
  it("falls back to the site language, then English", () => {
    assert.equal(detectSpeechLang("ciao", { siteLang: "it" }), "it");
    assert.equal(detectSpeechLang("hello", { siteLang: "ar" }), "en");
    assert.equal(detectSpeechLang("hello"), "en");
  });
  // THE READER'S NOTE (3.36): a French lesson read aloud whole was read with
  // the English voice, because each sentence is its own request and a
  // heading like this one has no French word to count.
  const lesson = [
    "Décrire son quotidien",
    "",
    "C'est ma vie !",
    "",
    "Aujourd'hui, le témoignage de Julie. Je finis mon travail à 18 heures.",
    "",
    "Le matin",
    "",
    "Je me lève tôt et je prends le bus.",
  ].join("\n");
  it("a sentence with no French word of its own was English — the bug", () => {
    assert.equal(detectSpeechLang("Décrire son quotidien."), "en");
    assert.equal(detectSpeechLang("Le matin."), "en");
  });
  it("…and is French once the passage it is read in says so", () => {
    assert.equal(passageSpeechLang(lesson), "fr");
    assert.equal(detectSpeechLang("Décrire son quotidien.", { passage: "fr" }), "fr");
    assert.equal(detectSpeechLang("Le matin.", { passage: passageSpeechLang(lesson) }), "fr");
    // Every sentence of the lesson, as the player sends them.
    for (const s of splitSentencesIn(lesson)) {
      assert.equal(detectSpeechLang(s.text, { context: s.block, passage: passageSpeechLang(lesson) }), "fr", s.text);
    }
  });
  it("the passage is counted by line: an English note quoting one French line stays English", () => {
    const english = [
      "Notes from the meeting about the new library.",
      "We will open on Monday and close on Friday afternoons.",
      "The mayor said: « Je suis très content de cette bibliothèque. »",
      "Parking is free for the first hour of every visit.",
    ].join("\n");
    assert.equal(passageSpeechLang(english), null);
    assert.equal(detectSpeechLang("Parking is free.", { passage: passageSpeechLang(english) }), "en");
    // The French line itself is still French on its own evidence.
    assert.equal(detectSpeechLang("Je suis très content de cette bibliothèque."), "fr");
  });
  it("script and the note's own lang still come first", () => {
    assert.equal(detectSpeechLang("図書館", { passage: "fr" }), "ja");
    assert.equal(detectSpeechLang("hello there", { noteLang: "en", passage: "fr" }), "en");
    assert.equal(detectSpeechLang("hello", { passage: "de" }), "en", "a passage language we do not speak is no hint");
  });
  it("each sentence carries the paragraph it came from", () => {
    const got = splitSentencesIn("One. Two!\n\nThree");
    assert.deepEqual(got, [
      { text: "One.", block: "One. Two!" },
      { text: "Two!", block: "One. Two!" },
      { text: "Three", block: "Three" },
    ]);
  });
  it("the transcription language is not a hint: no setting of voice notes reaches the detector", () => {
    // detectSpeechLang takes its hints and nothing else — the "Voice notes:
    // transcription language" row cannot leak into what is read aloud.
    assert.equal(detectSpeechLang.length <= 2, true);
    assert.equal(detectSpeechLang("C'est ma vie !", { siteLang: "en" }), "fr");
  });
  it("reads lang tags and frontmatter", () => {
    assert.equal(speakLangOf("fr-CA"), "fr");
    assert.equal(speakLangOf("pt_BR"), "pt");
    assert.equal(speakLangOf("de"), null);
    assert.equal(frontmatterLang("title: x\nlang: fr\n"), "fr");
    assert.equal(frontmatterLang('language: "ja"'), "ja");
    assert.equal(frontmatterLang("title: x"), null);
  });
});

describe("splitSentences", () => {
  it("French: spaced ! and ?, guillemets, decimals and M. stay whole", () => {
    assert.deepEqual(
      splitSentences("Bonjour ! Comment allez-vous ? Très bien, merci. M. Dupont a fait 3,5 km et 3.5 miles. Il a dit « Allons-y. » Puis il est parti…"),
      ["Bonjour !", "Comment allez-vous ?", "Très bien, merci.", "M. Dupont a fait 3,5 km et 3.5 miles.", "Il a dit « Allons-y. »", "Puis il est parti…"],
    );
  });
  it("the narrow no-break space French sets before ? is not a sentence", () => {
    assert.deepEqual(splitSentences("Vraiment ? Oui !"), ["Vraiment ?", "Oui !"]);
  });
  it("Japanese: 。!? end a sentence with no space after them", () => {
    assert.deepEqual(splitSentences("昨日、図書館へ行きました。本を三冊借りました!面白かったですか?はい。"), [
      "昨日、図書館へ行きました。",
      "本を三冊借りました!",
      "面白かったですか?",
      "はい。",
    ]);
  });
  it("Japanese: a closing bracket belongs to the sentence it ends", () => {
    assert.deepEqual(splitSentences("「行きましょう。」と彼は言った。"), ["「行きましょう。」", "と彼は言った。"]);
  });
  it("Arabic: the Arabic question mark ends a sentence, the Arabic comma does not", () => {
    assert.deepEqual(splitSentences("ذهبتُ إلى المكتبة. هل قرأتَ الكتاب؟ نعم، قرأته."), [
      "ذهبتُ إلى المكتبة.",
      "هل قرأتَ الكتاب؟",
      "نعم، قرأته.",
    ]);
  });
  it("English: abbreviations and initials are not boundaries; ellipses and ?! are", () => {
    assert.deepEqual(splitSentences("Dr. Smith met J. R. R. Tolkien in 1954. They talked, e.g. about trees... Really?! Yes."), [
      "Dr. Smith met J. R. R. Tolkien in 1954.",
      "They talked, e.g. about trees...",
      "Really?!",
      "Yes.",
    ]);
  });
  it("a blank line is a boundary, a single newline is a space", () => {
    assert.deepEqual(splitSentences("Heading\n\nLine two without stop\nstill two"), ["Heading", "Line two without stop still two"]);
  });
  it("drops pieces with no letters or digits", () => {
    assert.deepEqual(splitSentences("… . ! Word."), ["Word."]);
  });
  it("a sentence past the cap is cut where a reader breathes", () => {
    const long = Array.from({ length: 40 }, (_, i) => `clause number ${i}`).join(", ") + ".";
    const parts = splitSentences(long);
    assert.ok(parts.length > 1);
    for (const p of parts) assert.ok(p.length <= SENTENCE_MAX, p);
    assert.equal(parts.join(" "), long);
  });
});

describe("engines", () => {
  const both = new Set<SpeakEngineId>(["light", "natural"]);
  const light = new Set<SpeakEngineId>(["light"]);
  const none = new Set<SpeakEngineId>();
  it("the choice wins where both engines speak", () => {
    assert.equal(engineFor("fr", "light", both), "light");
    assert.equal(engineFor("fr", "natural", both), "natural");
    assert.equal(engineFor("en", "natural", light), "light");
  });
  it("Arabic is always Piper, Japanese always Kokoro", () => {
    assert.equal(engineFor("ar", "natural", both), "light");
    assert.equal(engineFor("ja", "light", both), "natural");
    assert.equal(engineFor("ja", "light", light), null);
    assert.equal(engineNeeded("ja", "light"), "natural");
    assert.equal(engineNeeded("ar", "natural"), "light");
    assert.equal(engineFor("en", "light", none), null);
  });
  it("a language an engine's self-test refused goes to the other engine, or names it", () => {
    const refused = { natural: ["fr"] as const };
    assert.equal(engineFor("fr", "natural", both, refused), "light");
    assert.equal(engineFor("fr", "natural", new Set<SpeakEngineId>(["natural"]), refused), null);
    assert.equal(engineNeeded("fr", "natural", refused), "light");
    assert.equal(engineFor("en", "natural", both, refused), "natural", "only the refused language moves");
    assert.deepEqual(
      refusalsOf({
        light: { check: { at: 1, langs: { fr: { ok: true } } } },
        natural: { check: { at: 1, langs: { ja: { ok: true }, fr: { ok: false, why: "english" }, es: { ok: false, why: "stopped" } } } },
      }),
      { natural: ["fr", "es"] },
    );
  });
  it("every built-in voice wears its gender, and French has a man's voice (Light's Pierre)", () => {
    for (const e of SPEAK_ENGINES) {
      for (const list of Object.values(SPEAK_VOICES[e])) for (const v of list ?? []) assert.ok(v.gender === "m" || v.gender === "f", v.id);
    }
    assert.ok(SPEAK_VOICES.light.fr?.some((v) => v.gender === "m"));
    assert.ok(!SPEAK_VOICES.natural.fr?.some((v) => v.gender === "m"), "Kokoro has no man's French voice — the picker says so");
    // Kokoro's own naming agrees with the table: the second letter is the gender.
    for (const list of Object.values(SPEAK_VOICES.natural)) for (const v of list ?? []) assert.equal(v.gender, v.id[1], v.id);
  });
  it("Pierre is one speaker of a model that downloads on choice; each language's first voice comes with Install", () => {
    const pierre = builtinVoice("fr_FR-upmc-medium#pierre");
    assert.deepEqual([pierre?.engine, pierre?.lang, pierre?.voice.file, pierre?.voice.speaker], ["light", "fr", "fr_FR-upmc-medium", 1]);
    assert.equal(builtinVoice("fr_FR-upmc-medium#jessica")?.voice.speaker, 0);
    assert.ok(isOnChoiceVoice("fr_FR-upmc-medium#pierre"));
    assert.ok(!isOnChoiceVoice("fr_FR-siwis-medium"));
    assert.ok(!isOnChoiceVoice("jm_kumo"), "a Kokoro voice is a row of the one voices file");
    assert.ok(isVoiceOf("fr", "fr_FR-upmc-medium#pierre"));
    // Not on disk yet: the language's first voice speaks meanwhile.
    assert.equal(voiceFor("light", "fr", "fr_FR-upmc-medium#pierre", () => false), "fr_FR-siwis-medium");
    assert.equal(voiceFor("light", "fr", "fr_FR-upmc-medium#pierre", () => true), "fr_FR-upmc-medium#pierre");
  });
  it("voices: the pick when the engine has it, else the first", () => {
    assert.equal(voiceFor("natural", "ja", "jm_kumo"), "jm_kumo");
    assert.equal(voiceFor("natural", "ja", "nope"), "jf_alpha");
    assert.equal(voiceFor("light", "ja"), null);
    assert.ok(isVoiceOf("en", "af_heart"));
    assert.ok(!isVoiceOf("fr", "af_heart"));
  });
  it("settings fill their defaults: Light, rate 1, not public", () => {
    assert.deepEqual(speakEffective(undefined), { engine: "light", rate: 1, voices: {}, public: false, voicesDir: null, external: null });
    assert.deepEqual(speakEffective({ engine: "natural", rate: 0.8, public: true, voices: { ja: "jm_kumo" } }), {
      engine: "natural",
      rate: 0.8,
      voices: { ja: "jm_kumo" },
      public: true,
      voicesDir: null,
      external: null,
    });
    assert.equal(speakEffective({ rate: 3 }).rate, 1);
    // This machine's two arrive from speak-local.json, not the settings.
    assert.equal(speakEffective(undefined, { voicesDir: "/v", external: null }).voicesDir, "/v");
  });
});

describe("what is spoken", () => {
  it("furigana is spoken as its base, once — never base and reading", () => {
    assert.equal(speakFurigana("{漢字|かん|じ}を読む"), "漢字を読む");
    assert.equal(speakFurigana("{東京|とうきょう}"), "東京");
    assert.equal(speakFurigana("no ruby"), "no ruby");
  });
  it("a note is read as prose: no frontmatter, code, markup or footnote marks", () => {
    const said = speechTextOfNote(
      "---\nlang: fr\n---\n# Title\n\nSome **bold** and [[Link|alias]] with {漢字|かん|じ} and `code`[^1].\n\n```js\nlet x = 1;\n```\n\n> [!note] Callout\n> body\n\n[^1]: the note\n",
    );
    assert.ok(!said.includes("lang:"));
    assert.ok(!said.includes("let x"));
    assert.ok(!said.includes("**"));
    assert.ok(!said.includes("[^1]"));
    assert.ok(!said.includes("[!note]"));
    assert.match(said, /Some bold and alias with 漢字 and/);
    assert.match(said, /Title/);
    assert.match(said, /Callout/);
  });
  it("closeSentence gives a bare word its language's full stop", () => {
    assert.equal(closeSentence("図書館", "ja"), "図書館。");
    assert.equal(closeSentence("grenouille", "fr"), "grenouille.");
    assert.equal(closeSentence("Déjà fini ?", "fr"), "Déjà fini ?");
    assert.equal(closeSentence("「はい」", "ja"), "「はい」");
    assert.equal(closeSentence("كتاب", "ar"), "كتاب.");
  });
});