Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions kokoro.js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-----|---------|---------|
| `<phoneme>` | Inject exact pronunciation | `<phoneme alphabet="ipa" ph="wˈɜːld">world</phoneme>` |
| `<break>` | Insert a timed silence | `<break time="500ms"/>` or `<break time="1.5s"/>` |
| `<sub>` | Substitute spoken text | `<sub alias="World Wide Web Consortium">W3C</sub>` |
| `<say-as interpret-as="characters">` | Spell out letter by letter | `<say-as interpret-as="characters">SQL</say-as>` |
| `<say-as interpret-as="ordinal">` | Read as an ordinal number | `<say-as interpret-as="ordinal">3</say-as>` → "third" |
| `<say-as interpret-as="number">` | Read as a cardinal number | `<say-as interpret-as="number">42</say-as>` → "forty-two" |

Malformed or unknown tags degrade gracefully to plain-text synthesis — the library never throws on SSML input.

### `<phoneme>` 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 `<phoneme>`.

**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]
Expand Down
97 changes: 97 additions & 0 deletions kokoro.js/src/kokoro.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,68 @@ 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);
}

/**
* @typedef {Object} GenerateOptions
Expand Down Expand Up @@ -74,6 +133,21 @@ export class KokoroTTS {
async generate(text, { voice = "af_heart", speed = 1 } = {}) {
const language = this._validate_voice(voice);

// If the text contains <break> tags, split into text/silence segments,
// generate each independently, and concatenate with cross-fades.
if (hasSSML(text) && text.includes("<break")) {
const segments = splitAtBreaks(text);
const audios = await Promise.all(
segments.map(async (seg) => {
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,
Expand Down Expand Up @@ -118,6 +192,29 @@ 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 <break> tags, extract breaks
// first and interleave silence segments with the sentence stream.
if (typeof text === "string" && hasSSML(text) && text.includes("<break")) {
const topSegments = splitAtBreaks(text);
for (const seg of topSegments) {
if (seg.type === "break") {
yield { text: "", phonemes: "", audio: generateSilence(seg.ms) };
continue;
}
// Process each text segment through the normal sentence-splitting path.
const splitter = new TextSplitterStream();
splitter.push(seg.value);
splitter.close();
Comment thread
Tgenz1213 marked this conversation as resolved.
for await (const sentence of splitter) {
const phonemes = await phonemize(sentence, language);
const { input_ids } = this.tokenizer(phonemes, { truncation: true });
const audio = await this.generate_from_ids(input_ids, { voice, speed });
yield { text: sentence, phonemes, audio };
}
}
return;
}

/** @type {TextSplitterStream} */
let splitter;
if (text instanceof TextSplitterStream) {
Expand Down
113 changes: 112 additions & 1 deletion kokoro.js/src/phonemize.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>} 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<string>} 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
Expand All @@ -172,6 +278,11 @@ const PUNCTUATION_PATTERN = new RegExp(`(\\s*[${escapeRegExp(PUNCTUATION)}]+\\s*
* @returns {Promise<string>} 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);
Expand Down
Loading
Loading