diff --git a/kokoro.js/README.md b/kokoro.js/README.md index 989a2afa..da58a7b6 100644 --- a/kokoro.js/README.md +++ b/kokoro.js/README.md @@ -75,6 +75,48 @@ splitter.close(); // splitter.flush(); ``` +## SSML + +`kokoro-js` supports a subset of [SSML](https://www.w3.org/TR/speech-synthesis11/) for precise pronunciation and pacing control. Tags are resolved as a pure pre-processing step — there is no overhead for plain-text inputs. + +### Supported tags + +| Tag | Purpose | Example | +|-----|---------|---------| +| `` | Inject exact pronunciation | `world` | +| `` | Insert a timed silence | `` or `` | +| `` | Substitute spoken text | `W3C` | +| `` | Spell out letter by letter | `SQL` | +| `` | Read as an ordinal number | `3` → "third" | +| `` | Read as a cardinal number | `42` → "forty-two" | + +Malformed or unknown tags degrade gracefully to plain-text synthesis — the library never throws on SSML input. + +### `` and eSpeak IPA notation + +The `ph` attribute accepts **eSpeak IPA notation**, which differs from standard (broad) IPA: + +**Stress marks** must be placed immediately before the stressed **vowel**, not before the syllable onset: + +``` +✅ eSpeak: wˈɜːld (ˈ directly before the vowel ɜ) +❌ Standard: ˈwɜːld (ˈ before the consonant onset w) +``` + +Placing `ˈ` before a consonant causes the model to vocalize it as a sound (often heard as "ah") rather than applying stress. This is the most common source of unexpected output when using ``. + +**English rhotic** is `ɹ`, not `r`: +``` +✅ eSpeak: ɹɪd +❌ Standard: rɪd +``` + +To find the correct eSpeak IPA for any word, run: + +```bash +espeak-ng --ipa -q -v en-us "word" +``` + ## Voices/Samples > [!TIP] diff --git a/kokoro.js/src/kokoro.js b/kokoro.js/src/kokoro.js index b2f58b07..eea8899e 100644 --- a/kokoro.js/src/kokoro.js +++ b/kokoro.js/src/kokoro.js @@ -2,9 +2,85 @@ import { env as hf, StyleTextToSpeech2Model, AutoTokenizer, Tensor, RawAudio } f import { phonemize } from "./phonemize.js"; import { TextSplitterStream } from "./splitter.js"; import { getVoiceData, VOICES } from "./voices.js"; +import { hasSSML, splitAtBreaks } from "./ssml.js"; const STYLE_DIM = 256; const SAMPLE_RATE = 24000; +const XFADE_LEN = Math.round(SAMPLE_RATE * 0.008); // 8 ms linear cross-fade + +/** + * Generate a silent audio segment. + * @param {number} durationMs Duration in milliseconds + * @returns {RawAudio} + */ +function generateSilence(durationMs) { + const numSamples = Math.round((SAMPLE_RATE * durationMs) / 1000); + return new RawAudio(new Float32Array(numSamples), SAMPLE_RATE); +} + +/** + * Concatenate multiple RawAudio segments with a short linear cross-fade at each boundary + * to prevent clicks or pops at splice points. + * @param {RawAudio[]} audios + * @returns {RawAudio} + */ +function concatAudio(audios) { + if (audios.length === 0) return new RawAudio(new Float32Array(0), SAMPLE_RATE); + if (audios.length === 1) return new RawAudio(audios[0].audio.slice(), SAMPLE_RATE); + + // Clamp cross-fade length to half the shortest segment so it never exceeds any segment. + const xLen = Math.min(XFADE_LEN, ...audios.map((a) => Math.floor(a.audio.length / 2))); + const totalLen = audios.reduce((s, a) => s + a.audio.length, 0) - xLen * (audios.length - 1); + const out = new Float32Array(totalLen); + + let pos = 0; + for (let i = 0; i < audios.length; i++) { + const seg = audios[i].audio; + const isFirst = i === 0; + const isLast = i === audios.length - 1; + + // Blend this segment's fade-in into the fade-out region already written by the previous segment. + if (!isFirst) { + for (let j = 0; j < xLen; j++) { + out[pos + j] += seg[j] * (j / xLen); + } + pos += xLen; + } + + // Copy the flat (non-overlapping) middle portion of this segment. + const flatStart = isFirst ? 0 : xLen; + const flatEnd = isLast ? seg.length : seg.length - xLen; + out.set(seg.subarray(flatStart, flatEnd), pos); + pos += flatEnd - flatStart; + + // Write a fade-out tail; the next segment's fade-in will be added on top. + if (!isLast) { + for (let j = 0; j < xLen; j++) { + out[pos + j] = seg[seg.length - xLen + j] * (1 - j / xLen); + } + // pos is intentionally NOT advanced here — the next iteration's fade-in writes to the same region. + } + } + + return new RawAudio(out, SAMPLE_RATE); +} + +/** + * Split text into stream chunks using the optional split pattern. + * @param {string} text + * @param {RegExp|null} split_pattern + * @returns {string[]} + */ +function splitTextIntoChunks(text, split_pattern) { + if (!split_pattern) { + return [text]; + } + + return text + .split(split_pattern) + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0); +} /** * @typedef {Object} GenerateOptions @@ -74,6 +150,21 @@ export class KokoroTTS { async generate(text, { voice = "af_heart", speed = 1 } = {}) { const language = this._validate_voice(voice); + // If the text contains tags, split into text/silence segments, + // generate each independently, and concatenate with cross-fades. + if (hasSSML(text) && text.includes(" { + if (seg.type === "break") return generateSilence(seg.ms); + const phonemes = await phonemize(seg.value, language); + const { input_ids } = this.tokenizer(phonemes, { truncation: true }); + return this.generate_from_ids(input_ids, { voice, speed }); + }), + ); + return concatAudio(audios); + } + const phonemes = await phonemize(text, language); const { input_ids } = this.tokenizer(phonemes, { truncation: true, @@ -118,19 +209,36 @@ export class KokoroTTS { async *stream(text, { voice = "af_heart", speed = 1, split_pattern = null } = {}) { const language = this._validate_voice(voice); + // If the input is a plain string containing tags, extract breaks + // first and interleave silence segments with the sentence stream. + if (typeof text === "string" && hasSSML(text) && text.includes(" chunk.trim()) - .filter((chunk) => chunk.length > 0) - : [text]; - splitter.push(...chunks); + splitter.push(...splitTextIntoChunks(text, split_pattern)); } else { throw new Error("Invalid input type. Expected string or TextSplitterStream."); } diff --git a/kokoro.js/src/phonemize.js b/kokoro.js/src/phonemize.js index 2ed8ef92..2cdb25fa 100644 --- a/kokoro.js/src/phonemize.js +++ b/kokoro.js/src/phonemize.js @@ -1,4 +1,5 @@ import { phonemize as espeakng } from "phonemizer"; +import { hasSSML, parseSSML } from "./ssml.js"; /** * Helper function to split a string on a regex, but keep the delimiters. @@ -95,7 +96,7 @@ function point_num(match) { * @param {string} text The text to normalize * @returns {string} The normalized text */ -function normalize_text(text) { +export function normalize_text(text) { return ( text // 1. Handle quotes and brackets @@ -164,6 +165,111 @@ function escapeRegExp(string) { const PUNCTUATION = ';:,.!?¡¿—…"«»“”(){}[]'; const PUNCTUATION_PATTERN = new RegExp(`(\\s*[${escapeRegExp(PUNCTUATION)}]+\\s*)+`, "g"); +/** + * Run eSpeak-NG on a pre-normalized text string and apply standard post-processing. + * Used internally so that phonemizeSSML can call normalize_text once per sub-segment + * without going through the full phonemize() entry-point (which would normalize again). + * @param {string} text Already-normalized text + * @param {"a"|"b"} language + * @returns {Promise} Phoneme string + */ +async function runEspeak(text, language) { + const lang = language === "a" ? "en-us" : "en"; + const sections = split(text, PUNCTUATION_PATTERN); + const ps = (await Promise.all(sections.map(async ({ match, text }) => (match ? text : (await espeakng(text, lang)).join(" "))))).join(""); + + let processed = ps + // https://en.wiktionary.org/wiki/kokoro#English + .replace(/kəkˈoːɹoʊ/g, "kˈoʊkəɹoʊ") + .replace(/kəkˈɔːɹəʊ/g, "kˈəʊkəɹəʊ") + .replace(/ʲ/g, "j") + .replace(/r/g, "ɹ") + .replace(/x/g, "k") + .replace(/ɬ/g, "l") + .replace(/(?<=[a-zɹː])(?=hˈʌndɹɪd)/g, " ") + .replace(/ z(?=[;:,.!?¡¿—…"«»"" ]|$)/g, "z"); + + if (language === "a") { + processed = processed.replace(/(?<=nˈaɪn)ti(?!ː)/g, "di"); + } + return processed; +} + +/** + * Expand characters in a string to a dot-separated uppercase form suitable for + * letter-by-letter eSpeak phonemization. normalize_text step 7 converts dots + * between uppercase letters to hyphens (e.g. "S.Q.L." → "S-Q-L."), which + * eSpeak reads as individual letter names. + * @param {string} text + * @returns {string} + */ +function expandCharacters(text) { + return [...text.toUpperCase()].join(".") + "."; +} + +/** + * Expand an integer string to its ordinal word or suffix form. + * Special irregular ordinals (first, second, …, twelfth) are returned as words; + * all others use the standard numeric-suffix form (e.g. "42nd", "11th"). + * @param {string} text + * @returns {string} + */ +function expandOrdinal(text) { + const n = parseInt(text, 10); + if (isNaN(n)) return text; + const SPECIALS = { 1: "first", 2: "second", 3: "third", 5: "fifth", 8: "eighth", 9: "ninth", 12: "twelfth" }; + if (SPECIALS[n]) return SPECIALS[n]; + const mod100 = n % 100; + const mod10 = n % 10; + const suffix = mod100 >= 11 && mod100 <= 13 ? "th" : mod10 === 1 ? "st" : mod10 === 2 ? "nd" : mod10 === 3 ? "rd" : "th"; + return `${n}${suffix}`; +} + +/** + * Phonemize an SSML string by processing each parsed segment individually. + * - text segments: normalize then run eSpeak + * - phoneme segments: inject the `ph` value directly, bypassing G2P. + * The value MUST be in eSpeak IPA notation — stress marks (ˈ ˌ) must appear + * immediately before the stressed vowel, not before the syllable onset. + * Example: wˈɜːld ✅ ˈwɜːld ❌ (the latter vocalizes ˈ as a sound). + * - sub segments: normalize the alias then run eSpeak + * - say-as segments: expand then normalize then run eSpeak + * normalize_text is called explicitly per-segment so it never runs twice on + * the same content (avoids double-normalization when phonemize() delegates here). + * @param {string} text Input text with SSML tags + * @param {"a"|"b"} language + * @returns {Promise} Phoneme string + */ +async function phonemizeSSML(text, language) { + const segments = parseSSML(text); + const parts = await Promise.all( + segments.map(async (seg) => { + switch (seg.type) { + case "phoneme": + return seg.ipa; + case "sub": + return runEspeak(normalize_text(seg.alias), language); + case "say-as": + if (seg.interpretAs === "characters") { + return runEspeak(normalize_text(expandCharacters(seg.text)), language); + } else if (seg.interpretAs === "ordinal") { + return runEspeak(normalize_text(expandOrdinal(seg.text)), language); + } else { + // number: let normalize_text handle number expansion as-is + return runEspeak(normalize_text(seg.text), language); + } + case "break": + // Breaks are handled at the audio level in kokoro.js; skip here. + return ""; + default: + return runEspeak(normalize_text(seg.value), language); + } + }), + ); + return parts.join("").trim(); +} + + /** * Phonemize text using the eSpeak-NG phonemizer * @param {string} text The text to phonemize @@ -172,6 +278,11 @@ const PUNCTUATION_PATTERN = new RegExp(`(\\s*[${escapeRegExp(PUNCTUATION)}]+\\s* * @returns {Promise} The phonemized text */ export async function phonemize(text, language = "a", norm = true) { + // Delegate to the SSML-aware path when the input contains tags. + if (hasSSML(text)) { + return phonemizeSSML(text, language); + } + // 1. Normalize text if (norm) { text = normalize_text(text); diff --git a/kokoro.js/src/ssml.js b/kokoro.js/src/ssml.js new file mode 100644 index 00000000..0019ff41 --- /dev/null +++ b/kokoro.js/src/ssml.js @@ -0,0 +1,212 @@ +/** + * Lightweight SSML parser for kokoro-js. + * + * Design principles: + * - Regex-based, never throws. Malformed or unknown tags are passed through as + * literal text so synthesis always degrades gracefully. + * - Input containing bare `<` that is not part of a recognised SSML tag (e.g. + * "2 < 3") is left unchanged. + * - Nested SSML is not supported; inner tags inside a known tag are treated as + * plain text content. + * + * Supported tags (priority-ordered per the feature spec): + * word + * / + * text + * text + * + * IPA format note: + * The `ph` attribute of `` must use **eSpeak IPA notation**, which + * differs from standard (broad) IPA in several important ways: + * + * 1. Stress marks go before the stressed **vowel**, not the syllable onset. + * - ✅ eSpeak: wˈɜːld (ˈ immediately before the vowel ɜ) + * - ❌ Standard: ˈwɜːld (ˈ before the consonant onset w) + * Placing ˈ before a consonant causes the model to vocalize it as a + * separate phoneme (often heard as an "ah" sound) rather than as stress. + * + * 2. The English rhotic is ɹ, not r. + * - ✅ eSpeak: ɹɪd ❌ Standard: rɪd + * + * To find the correct eSpeak IPA for any word, run: + * espeak-ng --ipa -q -v en-us "word" + */ + +/** + * @typedef {{ type: 'text'; value: string }} TextSegment + * @typedef {{ type: 'phoneme'; text: string; ipa: string }} PhonemeSegment + * @typedef {{ type: 'break'; ms: number }} BreakSegment + * @typedef {{ type: 'sub'; text: string; alias: string }} SubSegment + * @typedef {{ type: 'say-as'; text: string; interpretAs: 'characters'|'number'|'ordinal' }} SayAsSegment + * @typedef {TextSegment|PhonemeSegment|BreakSegment|SubSegment|SayAsSegment} SSMLSegment + */ + +/** + * Returns true if the text contains any `<` character, indicating it may + * contain SSML tags. Used as a fast guard to skip parsing on plain text. + * @param {string} text + * @returns {boolean} + */ +export function hasSSML(text) { + return text.includes("<"); +} + +/** + * Parse an XML-style attribute string into a key→value map. + * Handles both single- and double-quoted values. + * @param {string} attrStr + * @returns {Record} + */ +function parseAttrs(attrStr) { + const attrs = /** @type {Record} */ ({}); + const re = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g; + let m; + while ((m = re.exec(attrStr)) !== null) { + attrs[m[1]] = m[2] !== undefined ? m[2] : m[3]; + } + return attrs; +} + +/** + * Convert a SSML break `time` attribute value to milliseconds. + * Supports "500ms" and "1s" / "1.5s" formats; unknown formats return 0. + * @param {string} time + * @returns {number} + */ +export function parseBreakMs(time) { + if (!time) return 0; + const t = time.trim(); + if (t.endsWith("ms")) return parseFloat(t) || 0; + if (t.endsWith("s")) return (parseFloat(t) || 0) * 1000; + return 0; +} + +/** + * Parse an SSML string into an array of typed segments. + * + * Unknown or malformed tags are emitted as `{ type: 'text', value: originalText }`. + * Text between tags is emitted as-is. + * + * @param {string} text + * @returns {SSMLSegment[]} + */ +export function parseSSML(text) { + const segments = /** @type {SSMLSegment[]} */ ([]); + + // Matches either: + // self-closing: + // paired: inner + // Attribute values may contain single or double quoted strings. + const TAG_RE = /<([\w-]+)((?:\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')|[\w-]+))*)\s*(?:\/>()|>([\s\S]*?)<\/\1>)/g; + + let lastIndex = 0; + + for (const match of text.matchAll(TAG_RE)) { + const [fullMatch, tagName, attrStr, selfClose, inner] = match; + const matchStart = /** @type {number} */ (match.index); + + // Flush plain text before this tag. + if (matchStart > lastIndex) { + segments.push({ type: "text", value: text.slice(lastIndex, matchStart) }); + } + lastIndex = matchStart + fullMatch.length; + + const attrs = parseAttrs(attrStr); + const isSelfClosing = selfClose !== undefined; + const content = inner ?? ""; + + switch (tagName.toLowerCase()) { + case "phoneme": { + // Requires alphabet="ipa" and ph="..." — anything else falls through as text. + // The ph value must use eSpeak IPA notation (see module-level comment). + if (attrs["alphabet"]?.toLowerCase() === "ipa" && attrs["ph"]) { + segments.push({ type: "phoneme", text: content, ipa: attrs["ph"] }); + } else { + if (attrs["alphabet"] && attrs["alphabet"].toLowerCase() !== "ipa") { + console.warn(`[kokoro-js] : only alphabet="ipa" is supported (got "${attrs["alphabet"]}"); treating as plain text.`); + } + segments.push({ type: "text", value: content }); + } + break; + } + + case "break": { + if (!isSelfClosing) { + // Malformed — treat as plain text (content is empty for self-closing anyway). + segments.push({ type: "text", value: fullMatch }); + break; + } + const ms = parseBreakMs(attrs["time"] ?? ""); + segments.push({ type: "break", ms }); + break; + } + + case "sub": { + if (!attrs["alias"]) { + // Missing alias — fall back to inner content. + segments.push({ type: "text", value: content }); + } else { + segments.push({ type: "sub", text: content, alias: attrs["alias"] }); + } + break; + } + + case "say-as": { + const interpretAs = (attrs["interpret-as"] ?? "").toLowerCase(); + if (interpretAs === "characters" || interpretAs === "number" || interpretAs === "ordinal") { + segments.push({ type: "say-as", text: content, interpretAs: /** @type {'characters'|'number'|'ordinal'} */ (interpretAs) }); + } else { + // Unknown interpret-as value — pass inner text through. + segments.push({ type: "text", value: content }); + } + break; + } + + default: + // Unknown tag — pass the entire raw match through as literal text. + segments.push({ type: "text", value: fullMatch }); + break; + } + } + + // Flush any trailing plain text. + if (lastIndex < text.length) { + segments.push({ type: "text", value: text.slice(lastIndex) }); + } + + return segments; +} + +/** + * Split text into alternating text and break segments. + * Only `` tags are extracted; all other SSML tags are left intact in the + * text segments so that the phonemize layer can process them. + * + * @param {string} text + * @returns {Array<{type:'text',value:string}|BreakSegment>} + */ +export function splitAtBreaks(text) { + const BREAK_RE = //g; + const result = /** @type {Array<{type:'text',value:string}|BreakSegment>} */ ([]); + let lastIndex = 0; + + for (const match of text.matchAll(BREAK_RE)) { + const attrStr = match[1]; + const matchStart = /** @type {number} */ (match.index); + + if (matchStart > lastIndex) { + result.push({ type: "text", value: text.slice(lastIndex, matchStart) }); + } + lastIndex = matchStart + match[0].length; + + const ms = parseBreakMs(parseAttrs(attrStr)["time"] ?? ""); + result.push({ type: "break", ms }); + } + + if (lastIndex < text.length) { + result.push({ type: "text", value: text.slice(lastIndex) }); + } + + // Filter out empty text segments that would produce no audio. + return result.filter((s) => s.type === "break" || s.value.trim().length > 0); +} diff --git a/kokoro.js/tests/kokoro.stream.test.js b/kokoro.js/tests/kokoro.stream.test.js new file mode 100644 index 00000000..81f5f3dc --- /dev/null +++ b/kokoro.js/tests/kokoro.stream.test.js @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { KokoroTTS } from "../src/kokoro.js"; + +describe("KokoroTTS.stream", () => { + test("applies split_pattern to SSML text segments around tags", async () => { + const tokenizer = () => ({ input_ids: {} }); + const tts = new KokoroTTS({}, tokenizer); + + // Avoid model inference in this behavioral test. + tts.generate_from_ids = async () => ({ audio: new Float32Array(0), sampling_rate: 24000 }); + + const emittedTexts = []; + const input = 'Alpha. | Beta.Gamma. | Delta.'; + + for await (const chunk of tts.stream(input, { split_pattern: /\s*\|\s*/ })) { + emittedTexts.push(chunk.text); + } + + expect(emittedTexts).toEqual(["Alpha.Beta.", "", "Gamma.Delta."]); + expect(emittedTexts.join(" ")).not.toContain("|"); + }); +}); diff --git a/kokoro.js/tests/phonemize.test.js b/kokoro.js/tests/phonemize.test.js index 217f91a6..72b2b63f 100644 --- a/kokoro.js/tests/phonemize.test.js +++ b/kokoro.js/tests/phonemize.test.js @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { phonemize } from "../src/phonemize.js"; +import { phonemize, normalize_text } from "../src/phonemize.js"; const A_TEST_CASES = new Map([ ["‘Hello’", "həlˈoʊ"], @@ -77,6 +77,44 @@ const B_TEST_CASES = new Map([ ["X's mark", "ˈɛksɪz mˈɑːk"], ]); +describe("normalize_text", () => { + // Quotes and brackets + test("smart single quotes → straight", () => expect(normalize_text("\u2018Hello\u2019")).toBe("'Hello'")); + test("double curly quotes → straight", () => expect(normalize_text("\u201cHello\u201d")).toBe('"Hello"')); + test("parentheses → angle-quote brackets", () => expect(normalize_text("(Hello)")).toBe("«Hello»")); + + // Whitespace + test("multiple spaces collapsed", () => expect(normalize_text("Hello World")).toBe("Hello World")); + test("tabs → single space", () => expect(normalize_text("Hello\tWorld")).toBe("Hello World")); + + // Abbreviations + test("Dr. before capital → Doctor", () => expect(normalize_text("Dr. Smith")).toBe("Doctor Smith")); + test("Mr. → Mister", () => expect(normalize_text("Mr. Smith")).toBe("Mister Smith")); + test("Ms. → Miss", () => expect(normalize_text("Ms. Taylor")).toBe("Miss Taylor")); + test("Mrs. → Mrs", () => expect(normalize_text("Mrs. Johnson")).toBe("Mrs Johnson")); + test("etc. mid-sentence → etc", () => expect(normalize_text("apples, etc. Pears")).toBe("apples, etc. Pears")); + test("etc. end → etc", () => expect(normalize_text("apples, etc.")).toBe("apples, etc")); + + // Numbers + test("year 1990 → split", () => expect(normalize_text("1990")).toBe("19 90")); + test("time 12:34 → split", () => expect(normalize_text("12:34")).toBe("12 34")); + test("thousands separator removed", () => expect(normalize_text("1,000")).toBe("1000")); + test("decimal → point form", () => expect(normalize_text("12.34")).toBe("12 point 3 4")); + test("number range → 'to'", () => expect(normalize_text("10-20")).toBe("10 to 20")); + + // Currency + test("$100 → dollar form", () => expect(normalize_text("$100")).toBe("100 dollars")); + test("£1.50 → pound form", () => expect(normalize_text("£1.50")).toBe("1 pound and 50 pence")); + + // Possessives — the rule uppercases 's' only when preceded by an uppercase consonant + test("uppercase consonant possessive → uppercase S", () => expect(normalize_text("CAT's tail")).toBe("CAT'S tail")); + test("lowercase consonant possessive unchanged", () => expect(normalize_text("Cat's tail")).toBe("Cat's tail")); + + // Hyphenated initials + test("A.B.C → A-B-C", () => expect(normalize_text("A.B.C")).toBe("A-B-C")); + test("U.S.A. keeps trailing dot", () => expect(normalize_text("U.S.A.")).toBe("U-S-A.")); +}); + describe("phonemize", () => { describe("en-us", () => { for (const [input, expected] of A_TEST_CASES) { diff --git a/kokoro.js/tests/ssml.test.js b/kokoro.js/tests/ssml.test.js new file mode 100644 index 00000000..b3d2bc07 --- /dev/null +++ b/kokoro.js/tests/ssml.test.js @@ -0,0 +1,274 @@ +import { describe, test, expect } from "vitest"; +import { hasSSML, parseSSML, parseBreakMs, splitAtBreaks } from "../src/ssml.js"; +import { phonemize } from "../src/phonemize.js"; +// generateSilence and concatAudio are module-private; test them via their +// observable effects through the exported parseBreakMs + SAMPLE_RATE math. +const SAMPLE_RATE = 24000; + +// ─── hasSSML ───────────────────────────────────────────────────────────────── + +describe("hasSSML", () => { + test("plain text → false", () => expect(hasSSML("Hello world")).toBe(false)); + test("text with < but no tag → true (conservative)", () => expect(hasSSML("2 < 3")).toBe(true)); + test("valid SSML tag → true", () => expect(hasSSML('')).toBe(true)); +}); + +// ─── parseBreakMs ───────────────────────────────────────────────────────────── + +describe("parseBreakMs", () => { + test("ms suffix", () => expect(parseBreakMs("500ms")).toBe(500)); + test("s suffix (integer)", () => expect(parseBreakMs("1s")).toBe(1000)); + test("s suffix (decimal)", () => expect(parseBreakMs("1.5s")).toBe(1500)); + test("empty string → 0", () => expect(parseBreakMs("")).toBe(0)); + test("unknown format → 0", () => expect(parseBreakMs("100")).toBe(0)); + test("whitespace trimmed", () => expect(parseBreakMs(" 250ms ")).toBe(250)); +}); + +// ─── parseSSML ──────────────────────────────────────────────────────────────── + +describe("parseSSML", () => { + // Plain text + test("plain text → single text segment", () => { + expect(parseSSML("Hello world")).toEqual([{ type: "text", value: "Hello world" }]); + }); + + test("empty string → empty array", () => { + expect(parseSSML("")).toEqual([]); + }); + + // + test(" → break segment", () => { + expect(parseSSML('')).toEqual([{ type: "break", ms: 500 }]); + }); + + test(" → break segment", () => { + expect(parseSSML('')).toEqual([{ type: "break", ms: 1500 }]); + }); + + test(" with no time → ms=0", () => { + expect(parseSSML("")).toEqual([{ type: "break", ms: 0 }]); + }); + + test("text around break split correctly", () => { + expect(parseSSML('HelloWorld')).toEqual([ + { type: "text", value: "Hello" }, + { type: "break", ms: 500 }, + { type: "text", value: "World" }, + ]); + }); + + // + test("word → phoneme segment", () => { + expect(parseSSML('world')).toEqual([ + { type: "phoneme", text: "world", ipa: "wɜːld" }, + ]); + }); + + test(" without ph → text segment (fallback)", () => { + expect(parseSSML("world")).toEqual([ + { type: "text", value: "world" }, + ]); + }); + + test(" with non-ipa alphabet → text segment (fallback)", () => { + expect(parseSSML('world')).toEqual([ + { type: "text", value: "world" }, + ]); + }); + + // + test("text → sub segment", () => { + expect(parseSSML('W3C')).toEqual([ + { type: "sub", text: "W3C", alias: "World Wide Web Consortium" }, + ]); + }); + + test(" without alias → text segment (fallback)", () => { + expect(parseSSML("W3C")).toEqual([{ type: "text", value: "W3C" }]); + }); + + // + test(" → say-as segment", () => { + expect(parseSSML('SQL')).toEqual([ + { type: "say-as", text: "SQL", interpretAs: "characters" }, + ]); + }); + + test(" → say-as segment", () => { + expect(parseSSML('42')).toEqual([ + { type: "say-as", text: "42", interpretAs: "ordinal" }, + ]); + }); + + test(" → say-as segment", () => { + expect(parseSSML('100')).toEqual([ + { type: "say-as", text: "100", interpretAs: "number" }, + ]); + }); + + test(" with unknown interpret-as → text segment (fallback)", () => { + expect(parseSSML('2024-01-01')).toEqual([ + { type: "text", value: "2024-01-01" }, + ]); + }); + + // Unknown tags + test("unknown tag → literal text pass-through", () => { + expect(parseSSML("hello")).toEqual([ + { type: "text", value: "hello" }, + ]); + }); + + // Malformed / edge cases + test("bare < not part of a tag → literal text", () => { + expect(parseSSML("2 < 3")).toEqual([{ type: "text", value: "2 < 3" }]); + }); + + // Mixed + test("mixed text and tags", () => { + expect(parseSSML('Say CSS and done.')).toEqual([ + { type: "text", value: "Say " }, + { type: "sub", text: "CSS", alias: "Cascading Style Sheets" }, + { type: "text", value: " and " }, + { type: "break", ms: 300 }, + { type: "text", value: " done." }, + ]); + }); +}); + +// ─── splitAtBreaks ──────────────────────────────────────────────────────────── + +describe("splitAtBreaks", () => { + test("no breaks → single text segment", () => { + expect(splitAtBreaks("Hello world")).toEqual([{ type: "text", value: "Hello world" }]); + }); + + test("single break → text, break, text", () => { + expect(splitAtBreaks('HelloWorld')).toEqual([ + { type: "text", value: "Hello" }, + { type: "break", ms: 500 }, + { type: "text", value: "World" }, + ]); + }); + + test("multiple breaks", () => { + expect(splitAtBreaks('ABC')).toEqual([ + { type: "text", value: "A" }, + { type: "break", ms: 100 }, + { type: "text", value: "B" }, + { type: "break", ms: 200 }, + { type: "text", value: "C" }, + ]); + }); + + test("non-break SSML tags left intact in text segments", () => { + const result = splitAtBreaks('Hello world!'); + expect(result).toEqual([ + { type: "text", value: 'Hello world' }, + { type: "break", ms: 500 }, + { type: "text", value: "!" }, + ]); + }); + + test("whitespace-only text between breaks is filtered out", () => { + const result = splitAtBreaks(' '); + expect(result).toEqual([ + { type: "break", ms: 100 }, + { type: "break", ms: 200 }, + ]); + }); +}); + +// ─── phonemize() with SSML tags ─────────────────────────────────────────────── + +describe("phonemize with SSML", () => { + // Fast path: plain text is unchanged relative to calling phonemize without SSML. + test("plain text fast-path is unaffected", async () => { + expect(await phonemize("Hello World")).toEqual("həlˈoʊ wˈɜːld"); + }); + + // — IPA injected directly, G2P bypassed. + test(" injects IPA for tagged word", async () => { + const result = await phonemize('Hello world!'); + expect(result).toContain("wɜːld"); + expect(result).toMatch(/həlˈoʊ/); + }); + + test(" without ipa alphabet falls back to plain text synthesis", async () => { + // No throw — "world" is phonemized normally. + const result = await phonemize('world'); + expect(result).toMatch(/wˈɜːld/); + }); + + // — alias replaces display text. + test(" synthesizes alias instead of display text", async () => { + const abbrev = await phonemize("W3C"); + const expanded = await phonemize("World Wide Web Consortium"); + const sub = await phonemize('W3C'); + expect(sub).toEqual(expanded); + expect(sub).not.toEqual(abbrev); + }); + + // + test(" reads each letter individually", async () => { + // "SQL" as characters should produce phonemes for S, Q, L separately. + // Plain "SQL" would be read as "sequel" by eSpeak. + const chars = await phonemize('SQL'); + const plain = await phonemize("SQL"); + expect(chars).not.toEqual(plain); + // Individual letter names should appear: ɛs (S), kjuː (Q), ɛl (L) + expect(chars).toMatch(/ˈɛs/); + }); + + // + test(" 1 → 'first'", async () => { + const result = await phonemize('1'); + expect(result).toMatch(/fˈɜːst/); + }); + + test(" 42 → '42nd'", async () => { + const result = await phonemize('42'); + const fortySecond = await phonemize("42nd"); + expect(result).toEqual(fortySecond); + }); + + test(" 11 → '11th' (not '11st')", async () => { + const result = await phonemize('11'); + const eleventhExpected = await phonemize("11th"); + expect(result).toEqual(eleventhExpected); + }); + + // + test(" behaves like plain number", async () => { + const ssml = await phonemize('1990'); + const plain = await phonemize("1990"); + expect(ssml).toEqual(plain); + }); + + // Mixed + test("mixed tags in one sentence", async () => { + const result = await phonemize('The W3C and SQL.'); + expect(result).toMatch(/wˈɜːld/); // from "World" + expect(result).toContain("ˈɛskjuːˈɛl"); // injected IPA + }); +}); + +// ─── generateSilence / concatAudio (via parseBreakMs) ───────────────────────── + +describe("break time parsing and silence sizing", () => { + test("500ms → 12 000 samples at 24 kHz", () => { + expect(Math.round(SAMPLE_RATE * parseBreakMs("500ms") / 1000)).toBe(12000); + }); + + test("1s → 24 000 samples at 24 kHz", () => { + expect(Math.round(SAMPLE_RATE * parseBreakMs("1s") / 1000)).toBe(24000); + }); + + test("1.5s → 36 000 samples at 24 kHz", () => { + expect(Math.round(SAMPLE_RATE * parseBreakMs("1.5s") / 1000)).toBe(36000); + }); + + test("0ms → 0 samples", () => { + expect(Math.round(SAMPLE_RATE * parseBreakMs("0ms") / 1000)).toBe(0); + }); +});