Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions packages/cli/src/tts/synthesize.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SupportedLang } from "./manager.js";

Expand Down Expand Up @@ -121,3 +123,14 @@ describe("synthesize — espeak-ng language code translation", () => {
expect(getCapturedArgv()?.[LANG_ARGV_INDEX]).toBe("es");
});
});

describe("synthesize — Kokoro voice token overflow", () => {
it("adaptively splits only the 510-entry voice overflow and concatenates the audio", () => {
const source = readFileSync(fileURLToPath(new URL("./synthesize.ts", import.meta.url)), "utf8");

expect(source).toContain("def is_voice_token_overflow(error):");
expect(source).toContain("def synthesize_text(value):");
expect(source).toContain("samples.extend(right_samples)");
expect(source).toContain('const SCRIPT_PATH = join(SCRIPT_DIR, "synth-v3.py")');
});
});
38 changes: 35 additions & 3 deletions packages/cli/src/tts/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { findPython, hasPythonPackage } from "./python.js";
// We pass it conditionally so older installs that only accept `voice=`/`speed=`
// continue to work (falling back to Kokoro's default phonemization).
const SYNTH_SCRIPT = `
import sys, json, inspect
import sys, json, inspect, re

model_path = sys.argv[1]
voices_path = sys.argv[2]
Expand All @@ -40,7 +40,39 @@ supports_lang = "lang" in inspect.signature(model.create).parameters
if lang and supports_lang:
kwargs["lang"] = lang

samples, sample_rate = model.create(text, **kwargs)
def is_voice_token_overflow(error):
return bool(re.search(
r"index \\d+ is out of bounds for axis 0 with size 510",
str(error),
re.IGNORECASE,
))

def split_for_retry(value):
midpoint = len(value) // 2
boundaries = [
index + 1
for index, char in enumerate(value)
if (char in "。!?!?\\n" or char.isspace()) and 0 < index < len(value) - 1
]
split_at = min(boundaries, key=lambda index: abs(index - midpoint)) if boundaries else midpoint
return value[:split_at], value[split_at:]

def synthesize_text(value):
try:
return model.create(value, **kwargs)
except IndexError as error:
if not is_voice_token_overflow(error) or len(value) < 2:
raise
left, right = split_for_retry(value)
left_samples, left_rate = synthesize_text(left)
right_samples, right_rate = synthesize_text(right)
if left_rate != right_rate:
raise RuntimeError("Kokoro returned inconsistent sample rates across text chunks")
samples = list(left_samples)
samples.extend(right_samples)
return samples, left_rate

samples, sample_rate = synthesize_text(text)
sf.write(output_path, samples, sample_rate)

duration = len(samples) / sample_rate
Expand All @@ -63,7 +95,7 @@ const ESPEAK_LANG_OVERRIDES: Partial<Record<SupportedLang, string>> = {
// The filename carries a version suffix so older installs automatically
// upgrade when the script body changes (e.g., adding the `lang` kwarg).
const SCRIPT_DIR = join(homedir(), ".cache", "hyperframes", "tts");
const SCRIPT_PATH = join(SCRIPT_DIR, "synth-v2.py");
const SCRIPT_PATH = join(SCRIPT_DIR, "synth-v3.py");

function ensureSynthScript(): string {
if (!existsSync(SCRIPT_PATH)) {
Expand Down
Loading