diff --git a/packages/cli/src/tts/synthesize.test.ts b/packages/cli/src/tts/synthesize.test.ts index 9855ef9193..91a5409f34 100644 --- a/packages/cli/src/tts/synthesize.test.ts +++ b/packages/cli/src/tts/synthesize.test.ts @@ -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"; @@ -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")'); + }); +}); diff --git a/packages/cli/src/tts/synthesize.ts b/packages/cli/src/tts/synthesize.ts index f7330bc823..21ee5f52c0 100644 --- a/packages/cli/src/tts/synthesize.ts +++ b/packages/cli/src/tts/synthesize.ts @@ -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] @@ -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 @@ -63,7 +95,7 @@ const ESPEAK_LANG_OVERRIDES: Partial> = { // 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)) {