diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c4ea20..27ee8d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,5 +20,6 @@ jobs: - run: npm run typecheck - run: npm test - run: python3 scripts/test_local_whisper.py + - run: python3 scripts/test_local_kokoro.py - run: npx playwright install --with-deps chromium - run: npm run test:e2e diff --git a/README.md b/README.md index 31d8741..c74e906 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,7 @@ Browser WebM/MP4/WAV recordings are converted in memory by ffmpeg to 16 kHz mono Each recording captures its listening settings before microphone permission is requested. Canceling or changing sessions discards late transcription and cannot submit it to the agent. Canceling closes local transport and terminates conversion, but whisper-server may continue inference internally; this is not a claim that HTTP disconnect preempts GPU computation. Inference has a 60-second timeout; check `agentflow local-whisper status`, the server's loaded model and the configured URL after engine errors. Tests and TDD evidence: [local transcription validation](docs/local-whisper-validation.md). Real microphone permission, audible playback and sleep/wake still require device acceptance. + +### Local spoken replies + +Use [Local Kokoro](docs/local-kokoro.md) for keyless, offline voice output, independently of local Whisper input. Explicit setup installs the verified model and CPU runtime; select Local Kokoro in Speaking settings. Native agent reasoning remains independently configured. diff --git a/docs/local-kokoro-validation.md b/docs/local-kokoro-validation.md new file mode 100644 index 0000000..09ca0ba --- /dev/null +++ b/docs/local-kokoro-validation.md @@ -0,0 +1,42 @@ +# Local Kokoro acceptance evidence + +Issue: [#24](https://github.com/nolanmak/Agentflow/issues/24). Reference QA: 2026-10-15, Apple M3, 24 GiB RAM, macOS; Node 26.5.1, Python 3.12 for real inference. See [setup and measured performance](local-kokoro.md). + +| Criterion | Evidence | +| --- | --- | +| AC1 pinning/offline | Hash-locked `kokoro-requirements.txt`; model/voices SHA-256 in `local-kokoro.py`; real engine and app executed under sandbox denying non-loopback outbound traffic | +| AC2 independent/keyless | `local-kokoro.test.ts` persistence, rejection of STT and remote origins, direct HTTP proxy/redirect and no-key-lookup tests; browser Local Kokoro settings test | +| AC3 lifecycle | `test_local_kokoro.py` asset validation, atomic install, explicit missing-model failure, owned control stop, invalid-origin rejection; setup/start/status/stop exercised with real engine | +| AC4 playback | Browser ordered retry test and exact-text chunking test; real `/api/synthesize` returned PCM WAV; CLI and MCP successfully executed native playback offline at saved 1.25× speed | +| AC5 interruption/retry | Browser pending-synthesis and delayed-replay tests; real Chromium WAV playback pause measured 1.3–2.8 ms (250 ms bound); no new agent turn on Replay; real HTTP stalled-response abort/recovery and runtime disconnect tests | +| AC6 failure boundaries | Runtime tests cover invalid model/voice, empty/oversized text, Unicode payloads, output bounds and deadlines; adapter tests cover malformed/oversized audio, cancellation and HTTP failures; lifecycle tests cover asset integrity, occupied ports, idempotency and startup recovery; full deterministic suites | +| AC7 real-engine measurements | Offline cold 1.975 s, warm 1.714–1.741 s for 5.735 s audio, RSS about754 MiB; full model selected after int8 CPU operator incompatibility; real CLI/MCP smoke succeeded | +| AC8 delivery | PR/CI and merge recorded in issue; task worktree cleaned after merge | + +## Actual test-first evidence + +- Adapter settings initially failed `Invalid/Unknown provider`; minimal provider/config support made them pass. +- Malformed WAV regression initially failed with `Missing expected rejection`; validation now rejects it. +- HTTP synthesis initially returned500 because the endpoint read a credential; selected local output now bypasses key lookup. +- Runtime first tests failed because `local-kokoro.py` did not exist. Disconnect tests later failed for missing synthesis helper before its implementation. +- Browser local output test failed because the provider option count was0, then passed after UI wiring. +- CLI help test failed because `local-kokoro --help` printed generic help without `state-dir`, then passed after lifecycle dispatch/build packaging. +- Voice-test interruption failed because Interrupt was disabled during synthesis. It now remains available and late results cannot play. +- Delayed replay overwrote Interrupted with Your turn. A generation guard fixes the reproduced failure. +- Speech failure was overwritten by Your turn and later queued sentences kept running. The regression now retains the error, stops the queue, and retries only on explicit Replay in original sentence order. +- Long reply chunking test failed before the helper existed; chunks now concatenate to the complete original input without splitting surrogate pairs. +- Real `afplay` rejected WAV saved as `.mp3` (`AudioFileOpen dta?`); the filename regression failed before container-aware extension handling. Same bytes as `.wav` and the real CLI/MCP path then succeeded. + +## Commands + +- `npm run typecheck` +- `npm test`:65 tests passed +- `npm run test:e2e`:19 tests passed, including real browser audio-element interruption +- `python3 scripts/test_local_kokoro.py`:15 tests passed +- `python3 scripts/test_local_whisper.py`:9 tests passed +- `node scripts/live-local-kokoro.mjs --play` under the sandbox profile in the setup guide: real app synthesis, CLI and MCP playback passed +- `git diff --check` + +The browser tests use synthetic audio and fake native sessions, while real-engine checks use synthetic text and disposable application settings. No personal recordings, native histories or cloud credentials are fixtures. Native audio-player success is observed, but speaker quality, microphone/echo behavior and sleep/wake are not human-verified here. General device QA remains tracked in #23. + +- Unicode request regression first failed HTTP413 for12000 escaped characters; the byte limit now accommodates the complete permitted Unicode text while retaining the character bound. diff --git a/docs/local-kokoro.md b/docs/local-kokoro.md new file mode 100644 index 0000000..2c9f2de --- /dev/null +++ b/docs/local-kokoro.md @@ -0,0 +1,55 @@ +# Local Kokoro speech output + +Kokoro speaks replies on your computer. It is independent of local Whisper input and the native agent's reasoning provider. No cloud key is read for local synthesis, and failure never falls back to a cloud voice. + +## Setup + +Install Python 3.12 or 3.13 (on macOS, `brew install python@3.12`), then run from a built Agentflow checkout: + +```sh +npm ci +npm run build +node dist/src/cli.js local-kokoro setup --python python3.12 +node dist/src/cli.js local-kokoro start +node dist/src/cli.js local-kokoro status +``` + +Setup explicitly downloads the pinned Python runtime dependencies, model and voices. It verifies SHA-256 checksums; inference needs no external network. The full model is roughly 311 MB, plus voice data and the Python environment. Default state is `~/.config/agentflow/local-kokoro` (`AGENTFLOW_HOME` overrides the parent directory). Use `--state-dir PATH` before the lifecycle subcommand for isolated state. A stopped runtime stays stopped until explicitly started; Agentflow startup does not download models or capture a microphone. + +In Speech & settings choose **Local Kokoro** for Speaking. Model: `kokoro`. Voice: `af_heart` (American English); other installed American/British voices can be listed at `http://127.0.0.1:17865/v1/audio/voices`. Save preferences and use **Test voice**. Playback speed applies in the browser or native player, independently of synthesis. The default endpoint is `http://127.0.0.1:17865`; only explicit loopback HTTP origins are accepted. The browser talks to Agentflow, which connects directly without proxy routing or redirects. + +```sh +node dist/src/cli.js speak --text "Local speech is ready." +node dist/src/cli.js local-kokoro stop +``` + +The MCP `speak` tool uses the same local provider and macOS playback path. Changing voice output does not change transcription, agent routing, or session identity. This integration currently exposes English voices, not custom voice cloning. + +## Failure and interruption + +Missing/corrupt assets require explicit `setup`; no download occurs during a speech request. `start` checks assets and reports an occupied port without stopping the other process. `stop` authenticates ownership through a private control token, not a guessed PID. + +Native replies are spoken in sentence order. Long unpunctuated text is split without discarding its tail. Interrupt immediately pauses browser playback, aborts the HTTP request, and invalidates late audio. The runtime finishes its current bounded CPU chunk, then detects disconnect and skips subsequent chunks. While busy it rejects concurrent work instead of queuing stale speech. Each request has a synthesis deadline and bounded audio output. If output fails, the error remains visible and remaining queued sentences stop; Replay retries speech without another agent turn. + +## Runtime and licenses + +Pinned `kokoro-onnx` 0.6.1 (MIT), ONNX Runtime 1.23.2 and NumPy 2.3.5 run on CPU. Kokoro v1.0 weights are Apache-2.0. The phonemizer/eSpeak dependency stack includes GPL-family components; the complete runtime is not Apache-only. Setup installs dependencies in a separate virtual environment; see package licenses before redistributing a bundled environment. + +The model and voice checksums are in `scripts/local-kokoro.py`; all Python package hashes are in `scripts/kokoro-requirements.txt`. The FP32 model is intentional: the tested int8 variant lacked a supported ARM CPU operator. + +Sources: [Kokoro model](https://huggingface.co/hexgrad/Kokoro-82M), [ONNX implementation](https://github.com/thewh1teagle/kokoro-onnx). + +## Validation + +Reference: Apple M3 Mac, 24 GiB RAM, CPU inference. Under a macOS sandbox denying external outbound networking, a 94-character synthetic sentence produced 5.735 seconds of valid mono 24 kHz PCM audio. Cold synthesis: 1.975 seconds. Three warm runs: 1.741, 1.714, 1.734 seconds. Runtime RSS after four runs: about 754 MiB. Readiness including asset verification took about 0.77 seconds. These are measurements on this device, not minimum hardware guarantees or voice-quality scores. + +Chromium playback of synthetic WAV on the reference Mac paused in about 3 ms after an interrupt; the test enforces 250 ms and verifies no additional agent turn during Replay. Automated device playback is distinct from a person confirming speaker quality. Microphone, echo, sleep/wake and human listening checks remain part of the broader device QA issue, not claimed here. + +To reproduce the opt-in real-engine app check after starting Kokoro, run from the repository root: + +```sh +npm run build +sandbox-exec -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))' node scripts/live-local-kokoro.mjs --play +``` + +This uses disposable application settings and synthetic text. `--play` exercises CLI and MCP through the real Mac audio player; omit it to validate audio without playback. Start the runtime under the same sandbox profile to also enforce offline model execution. The reference run returned HTTP 200 with 82,060 bytes of PCM WAV, CLI `Spoken.`, and MCP `Speech played on the local Mac.` No cloud credentials were read. This confirms successful audio-device execution, not a subjective listening assessment. diff --git a/public/app.ts b/public/app.ts index 8b242db..73a8038 100644 --- a/public/app.ts +++ b/public/app.ts @@ -10,7 +10,7 @@ import { parseRouterModels, } from "./contracts.js"; import { deepgramVoices, deepgramVoice } from "./deepgram-voices.js"; -import { sentences, VoiceGate } from "./voice.js"; +import { sentences, speechChunks, VoiceGate } from "./voice.js"; import { element as $ } from "./dom.js"; import { errorMessage, @@ -57,6 +57,7 @@ const names = { elevenlabs: "ElevenLabs", "9router": "9Router", "local-whisper": "Local Whisper", + "local-kokoro": "Local Kokoro", }; let token: string, settings: PublicSettings, @@ -134,8 +135,9 @@ function state(label: string, detail: string, kind = "idle") { $("state").textContent = label; $("hint").textContent = detail; $("orb").dataset.state = kind; - $("end").disabled = !call && !currentJob; - $("interrupt").disabled = !call && !currentJob && !audio; + $("end").disabled = !call && !currentJob && kind !== "speaking"; + $("interrupt").disabled = + !call && !currentJob && !audio && kind !== "speaking"; $("talk").textContent = recording ? "■ Send recording" : call @@ -259,11 +261,13 @@ async function select(s: SessionSummary) { } } function queueSpeech(text: string, g: number) { - const spoken = text; + for (const chunk of speechChunks(text)) queueSpeechChunk(chunk, g); +} +function queueSpeechChunk(spoken: string, g: number) { if (!spoken) return; speechQueue = speechQueue .then(async () => { - if (g !== generation) return; + if (g !== generation || lastSpeechError) return; state( "Speaking", selected?.agent === "claude" @@ -273,7 +277,7 @@ function queueSpeech(text: string, g: number) { ); const blob = await api("/synthesize", parseAudio, { method: "POST", - body: { text: spoken.slice(0, 12000) }, + body: { text: spoken }, signal: aborter.signal, }); if (g !== generation) return; @@ -393,7 +397,7 @@ async function send(text: string) { $("approval").hidden = true; } } - if (g !== generation) return; + if (g !== generation || lastSpeechError) return; state("Your turn", "Continue the conversation, or return to your terminal."); if (call && $("handsfree").checked && !lastSpeechError) await listen(); refresh().catch(() => {}); @@ -682,7 +686,11 @@ function paintSettings() { for (const side of ["stt", "tts"] as const) { $(`${side}Provider`).replaceChildren(); for (const [id, name] of Object.entries(names)) { - if (side === "tts" && id === "local-whisper") continue; + if ( + (side === "tts" && id === "local-whisper") || + (side === "stt" && id === "local-kokoro") + ) + continue; const o = new Option(name, id); $(`${side}Provider`).add(o); } @@ -693,6 +701,7 @@ function paintSettings() { $("ttsSpeed").value = String(settings.tts.speed ?? 1); $("routerUrl").value = settings.routerUrl; $("localWhisperUrl").value = settings.stt.baseUrl || "http://127.0.0.1:17864"; + $("localKokoroUrl").value = settings.tts.baseUrl || "http://127.0.0.1:17865"; $("sttLabel").textContent = names[settings.stt.provider] + " listens"; $("ttsLabel").textContent = (settings.tts.provider === "deepgram" && deepgramVoice(settings.tts.model) @@ -703,7 +712,7 @@ function paintSettings() { paintVoicePicker(); $("keyRows").replaceChildren(); for (const [id, name] of Object.entries(names)) { - if (id === "local-whisper") continue; + if (id === "local-whisper" || id === "local-kokoro") continue; const row = document.createElement("div"); row.className = "keyrow"; const label = document.createElement("span"); @@ -759,12 +768,13 @@ async function testVoice() { await stop(); lastSpeechError = false; aborter = new AbortController(); + const g = generation; queueSpeech( "Agentflow is ready. This is your selected voice. You can talk to your Codex and Claude Code sessions here.", generation, ); await speechQueue; - if (lastSpeechError) return; + if (lastSpeechError || g !== generation) return; state( "Voice test finished", "If you heard that, your speech output is connected.", @@ -784,9 +794,12 @@ $("replay").onclick = async () => { if (!lastReply) return; await stop(); aborter = new AbortController(); - for (const s of sentences(lastReply, true).ready) queueSpeech(s, generation); + const g = generation; + lastSpeechError = false; + for (const s of sentences(lastReply, true).ready) queueSpeech(s, g); await speechQueue; - state("Your turn", "Replay complete."); + if (g === generation && !lastSpeechError) + state("Your turn", "Replay complete."); }; $("composer").onsubmit = async (e) => { e.preventDefault(); @@ -854,6 +867,7 @@ $("settingsForm").onsubmit = async (e) => { provider: $("ttsProvider").value, model: $("ttsModel").value, voice: $("ttsVoice").value, + baseUrl: $("localKokoroUrl").value, speed: Number($("ttsSpeed").value), }, routerUrl: $("routerUrl").value, diff --git a/public/contracts.ts b/public/contracts.ts index e800db5..561ede2 100644 --- a/public/contracts.ts +++ b/public/contracts.ts @@ -1,6 +1,11 @@ export type AgentKind = "codex" | "claude"; export type Provider = - "deepgram" | "openai" | "elevenlabs" | "9router" | "local-whisper"; + | "deepgram" + | "openai" + | "elevenlabs" + | "9router" + | "local-whisper" + | "local-kokoro"; export type SpeechSide = "stt" | "tts"; export interface SpeechConfig { provider: Provider; @@ -106,9 +111,14 @@ export function errorCode(error: unknown): string | number | undefined { export function isProvider(value: unknown): value is Provider { return ( typeof value === "string" && - ["deepgram", "openai", "elevenlabs", "9router", "local-whisper"].includes( - value, - ) + [ + "deepgram", + "openai", + "elevenlabs", + "9router", + "local-whisper", + "local-kokoro", + ].includes(value) ); } export function isAgent(value: unknown): value is AgentKind { @@ -142,12 +152,16 @@ export function parseSettings(value: unknown): SettingsValue { model: string(c.model), ...(c.provider === "local-whisper" ? { baseUrl: localWhisperUrl(c.baseUrl) } - : {}), + : c.provider === "local-kokoro" + ? { baseUrl: localKokoroUrl(c.baseUrl) } + : {}), ...(typeof c.voice === "string" ? { voice: c.voice } : {}), }; } if (record(r.tts).provider === "local-whisper") throw Error("Local Whisper supports transcription only"); + if (record(r.stt).provider === "local-kokoro") + throw Error("Local Kokoro supports speech output only"); return { stt: side(r.stt), tts: { ...side(r.tts), speed: playbackSpeed(record(r.tts).speed) }, @@ -292,6 +306,7 @@ export function parsePublicSettings(value: unknown): PublicSettings { elevenlabs: boolean(cfg.elevenlabs), "9router": boolean(cfg["9router"]), "local-whisper": true, + "local-kokoro": true, }, defaults: { deepgram: defaults("deepgram"), @@ -299,6 +314,7 @@ export function parsePublicSettings(value: unknown): PublicSettings { elevenlabs: defaults("elevenlabs"), "9router": defaults("9router"), "local-whisper": { stt: "base.en", tts: "", voice: "" }, + "local-kokoro": { stt: "", tts: "kokoro", voice: "af_heart" }, }, }; } @@ -361,3 +377,15 @@ export function localWhisperUrl( if (u.hostname === "localhost") u.hostname = "127.0.0.1"; return u.origin; } + +export function localKokoroUrl( + value: unknown = "http://127.0.0.1:17865", +): string { + try { + return localWhisperUrl(value); + } catch { + throw Error( + "Local Kokoro requires a loopback HTTP URL without credentials, paths or queries", + ); + } +} diff --git a/public/dom.ts b/public/dom.ts index dc66215..0aeefe7 100644 --- a/public/dom.ts +++ b/public/dom.ts @@ -52,6 +52,7 @@ interface Elements { ttsSpeed: HTMLSelectElement; routerUrl: HTMLInputElement; localWhisperUrl: HTMLInputElement; + localKokoroUrl: HTMLInputElement; keyRows: HTMLElement; settingsNotice: HTMLElement; routerModels: HTMLButtonElement; diff --git a/public/index.html b/public/index.html index 684c1c8..4e7753d 100644 --- a/public/index.html +++ b/public/index.html @@ -3,5 +3,5 @@
YOUR WORK, OUT LOUD

Pick up the conversation.

Select a session, then start talking.
CODEX + CLAUDE CODE

Ready when you are

Your session. Your context. Now with a voice.

Deepgram listens/Deepgram speaks
No session selected
-
MAKE IT YOURS

Speech & settings

Choose who listens and who speaks. Keys stay in your Mac’s Keychain.

Listening · speech to text
Speaking · text to speech

Local Whisper transcribes on this Mac with no API key. Set up and start the engine with agentflow local-whisper setup and agentflow local-whisper start. The model field labels the model loaded by that server; it does not switch server models. Speaking and agent reasoning use their independently configured providers.

Provider keys

+
MAKE IT YOURS

Speech & settings

Choose who listens and who speaks. Keys stay in your Mac’s Keychain.

Listening · speech to text
Speaking · text to speech

Local Whisper transcribes on this Mac with no API key. Set up and start the engine with agentflow local-whisper setup and agentflow local-whisper start. The model field labels the model loaded by that server; it does not switch server models. Speaking and agent reasoning use their independently configured providers.

Local Kokoro speaks on this Mac without an API key. Run agentflow local-kokoro setup, then agentflow local-kokoro start. Use an installed English voice such as af_heart. Downloads happen only during setup; listening and agent reasoning stay independently configured.

Provider keys

New conversation

Uses your existing agent login. You can return to this conversation from the terminal.

diff --git a/public/voice.ts b/public/voice.ts index 58d09de..d1252e6 100644 --- a/public/voice.ts +++ b/public/voice.ts @@ -34,3 +34,23 @@ export class VoiceGate { return this.spoke && now - this.last > 900; } } + +// Preserve every character while respecting the speech API's UTF-16 text limit. +export function speechChunks(text: string): string[] { + const chunks: string[] = []; + while (text.length > 12000) { + let end = 12000; + const prefix = text.slice(0, end); + const whitespace = [...prefix.matchAll(/\s/g)].at(-1); + if (whitespace && whitespace.index > 0) end = whitespace.index + 1; + else if ( + /[\uD800-\uDBFF]/.test(text[end - 1]) && + /[\uDC00-\uDFFF]/.test(text[end]) + ) + end--; + chunks.push(text.slice(0, end)); + text = text.slice(end); + } + if (text) chunks.push(text); + return chunks; +} diff --git a/scripts/build-assets.ts b/scripts/build-assets.ts index 0c51c8b..e0abcec 100644 --- a/scripts/build-assets.ts +++ b/scripts/build-assets.ts @@ -9,3 +9,12 @@ await copyFile( resolve("scripts/local-whisper.py"), resolve("dist/scripts/local-whisper.py"), ); + +await copyFile( + resolve("scripts/local-kokoro.py"), + resolve("dist/scripts/local-kokoro.py"), +); +await copyFile( + resolve("scripts/kokoro-requirements.txt"), + resolve("dist/scripts/kokoro-requirements.txt"), +); diff --git a/scripts/kokoro-requirements.txt b/scripts/kokoro-requirements.txt new file mode 100644 index 0000000..6e52d39 --- /dev/null +++ b/scripts/kokoro-requirements.txt @@ -0,0 +1,174 @@ +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via phonemizer +cloudpickle==3.1.2 \ + --hash=sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414 \ + --hash=sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a + # via joblib +coloredlogs==15.0.1 \ + --hash=sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934 \ + --hash=sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0 + # via onnxruntime +dlinfo==2.0.0 \ + --hash=sha256:88a2bc04f51d01bc604cdc9eb1c3cc0bde89057532ca6a3e71a41f6235433e17 \ + --hash=sha256:b32cc18e3ea67c0ca9ca409e5b41eed863bd1363dbc9dd3de90fedf11b61e7bc + # via phonemizer +espeakng-loader==0.2.4 \ + --hash=sha256:08721baf27d13d461f6be6eed9a65277e70d68234ff484fd8b9897b222cdcb6d \ + --hash=sha256:41f1e08ac9deda2efd1ea9de0b81dab9f5ae3c4b24284f76533d0a7b1dd7abd7 \ + --hash=sha256:b77477ae2ddf62a748e04e49714eabb2f3a24f344166200b00539083bd669904 \ + --hash=sha256:d1e798141b46a050cdb75fcf3c17db969bb2c40394f3f4a48910655d547508b9 \ + --hash=sha256:d27cdca31112226e7299d8562e889d3e38a1e48055c9ee381b45d669072ee59f \ + --hash=sha256:d7a2928843eaeb2df82f99a370f44e8a630f59b02f9b0d1f168a03c4eeb76b89 + # via kokoro-onnx +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + # via onnxruntime +humanfriendly==10.0 \ + --hash=sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477 \ + --hash=sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc + # via coloredlogs +joblib==1.6.0 \ + --hash=sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03 \ + --hash=sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba + # via phonemizer +kokoro-onnx==0.6.1 \ + --hash=sha256:50c8de4950d601df41428ee5462a48c8a78bef441bf671f2492e070ef44d8a32 \ + --hash=sha256:7bbdb66dd53775f71088a99999a9aefdad240740daf54cb09410d8bb1e294e35 +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via sympy +numpy==2.3.5 \ + --hash=sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b \ + --hash=sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae \ + --hash=sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3 \ + --hash=sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0 \ + --hash=sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b \ + --hash=sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa \ + --hash=sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28 \ + --hash=sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e \ + --hash=sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017 \ + --hash=sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41 \ + --hash=sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e \ + --hash=sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63 \ + --hash=sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9 \ + --hash=sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8 \ + --hash=sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff \ + --hash=sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7 \ + --hash=sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139 \ + --hash=sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4 \ + --hash=sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748 \ + --hash=sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952 \ + --hash=sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd \ + --hash=sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b \ + --hash=sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce \ + --hash=sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f \ + --hash=sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5 \ + --hash=sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42 \ + --hash=sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7 \ + --hash=sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248 \ + --hash=sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e \ + --hash=sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3 \ + --hash=sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b \ + --hash=sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e \ + --hash=sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0 \ + --hash=sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa \ + --hash=sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a \ + --hash=sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5 \ + --hash=sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d \ + --hash=sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4 \ + --hash=sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c \ + --hash=sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52 \ + --hash=sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5 \ + --hash=sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d \ + --hash=sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1 \ + --hash=sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c \ + --hash=sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18 \ + --hash=sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7 \ + --hash=sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 \ + --hash=sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218 \ + --hash=sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2 \ + --hash=sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903 \ + --hash=sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c \ + --hash=sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c \ + --hash=sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234 \ + --hash=sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82 \ + --hash=sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39 \ + --hash=sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf \ + --hash=sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20 \ + --hash=sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946 \ + --hash=sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0 \ + --hash=sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9 \ + --hash=sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff \ + --hash=sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad \ + --hash=sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227 \ + --hash=sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10 \ + --hash=sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e \ + --hash=sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf \ + --hash=sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769 \ + --hash=sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310 \ + --hash=sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425 \ + --hash=sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013 \ + --hash=sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c \ + --hash=sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb \ + --hash=sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d \ + --hash=sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520 + # via + # kokoro-onnx + # onnxruntime +onnxruntime==1.23.2 \ + --hash=sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc \ + --hash=sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77 \ + --hash=sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95 \ + --hash=sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088 \ + --hash=sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b \ + --hash=sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435 \ + --hash=sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f \ + --hash=sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b \ + --hash=sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7 \ + --hash=sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c \ + --hash=sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c \ + --hash=sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612 \ + --hash=sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872 \ + --hash=sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2 \ + --hash=sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e \ + --hash=sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466 \ + --hash=sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3 \ + --hash=sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36 \ + --hash=sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321 \ + --hash=sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6 \ + --hash=sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e \ + --hash=sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145 + # via kokoro-onnx +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via onnxruntime +phonemizer==3.4.0 \ + --hash=sha256:5ff1215d0efa3606dd1b92449f6d71b5c1741efcc84c6d40c17bfaf64f6ded5f \ + --hash=sha256:e13231980c50bc671ec0466379ba027260ad9d61929952d8ae9665b3d0f251eb + # via kokoro-onnx +protobuf==7.36.2 \ + --hash=sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb \ + --hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 \ + --hash=sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728 \ + --hash=sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353 \ + --hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e \ + --hash=sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e \ + --hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e \ + --hash=sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf + # via onnxruntime +pyreadline3==3.5.6 ; sys_platform == 'win32' \ + --hash=sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf \ + --hash=sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d + # via humanfriendly +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via onnxruntime +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via phonemizer diff --git a/scripts/live-local-kokoro.mjs b/scripts/live-local-kokoro.mjs new file mode 100644 index 0000000..96071a0 --- /dev/null +++ b/scripts/live-local-kokoro.mjs @@ -0,0 +1,69 @@ +import { createApp } from "../dist/src/server.js"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +const dir = await mkdtemp("/tmp/agentflow-kokoro-app-"); +await writeFile( + dir + "/settings.json", + JSON.stringify({ + stt: { provider: "local-whisper", model: "base.en" }, + tts: { + provider: "local-kokoro", + model: "kokoro", + voice: "af_heart", + speed: 1.25, + }, + routerUrl: "http://127.0.0.1:20128", + }), +); +const app = await createApp({ + dir, + port: 0, + store: { + get: async () => { + throw Error("No keys allowed"); + }, + }, + agents: { init: async () => {}, close() {} }, +}); +async function cli(args, input) { + const p = spawn(process.execPath, ["dist/src/cli.js", ...args], { + env: { ...process.env, AGENTFLOW_HOME: dir, AGENTFLOW_URL: app.url }, + }); + let out = "", + err = ""; + p.stdout.on("data", (b) => (out += b)); + p.stderr.on("data", (b) => (err += b)); + p.stdin.end(input); + const code = await new Promise((r) => p.on("close", r)); + if (code) throw Error(err); + return out; +} +try { + const { token } = await (await fetch(app.url + "/api/bootstrap")).json(); + const r = await fetch(app.url + "/api/synthesize", { + method: "POST", + headers: { "x-agentflow-token": token, "content-type": "application/json" }, + body: JSON.stringify({ text: "Local speech is ready." }), + }); + const bytes = Buffer.from(await r.arrayBuffer()); + if (r.status !== 200 || bytes.toString("ascii", 0, 4) !== "RIFF") + throw Error("Invalid synthesis " + r.status); + console.log(JSON.stringify({ synthesize: r.status, bytes: bytes.length })); + if (process.argv.includes("--play")) { + console.log(await cli(["speak", "--text", "Local voice test."], "")); + console.log( + await cli( + ["mcp"], + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "speak", arguments: { text: "Local tool test." } }, + }) + "\n", + ), + ); + } +} finally { + await app.close(); + await rm(dir, { recursive: true, force: true }); +} diff --git a/scripts/local-kokoro.py b/scripts/local-kokoro.py new file mode 100644 index 0000000..fc8fb4a --- /dev/null +++ b/scripts/local-kokoro.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Explicit managed CPU Kokoro runtime. Setup needs Python 3.12–3.13; inference is offline.""" + +import argparse +import asyncio +import itertools +import select +import array +import contextlib +import fcntl +import http.server +import importlib.util +import io +import json +import os +from pathlib import Path +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.request +import wave + +ASSETS = { + "kokoro-v1.0.onnx": "beb0d1848dee9a49da392cc3df26958d46cfa35d321edf434f52949153f0df3a", + "voices-v1.0.bin": "bca610b8308e8d99f32e6fe4197e7ec01679264efed0cac9140fe9c29f1fbf7d", +} +RELEASE = ( + "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.1/" +) +# Share audited private-pipe/loopback ownership helpers, without starting Whisper. +spec = importlib.util.spec_from_file_location( + "whisper_lifecycle", Path(__file__).with_name("local-whisper.py") +) +lifecycle = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lifecycle) + + +def assets_status(state): + if any(not (state / name).is_file() for name in ASSETS): + return "missing_model" + if any(lifecycle.digest(state / name) != sha for name, sha in ASSETS.items()): + return "corrupt_model" + return "ready" + + +def install_asset(path, source, sha): + fd, name = tempfile.mkstemp(dir=path.parent, suffix=".partial") + try: + with os.fdopen(fd, "wb") as target: + shutil.copyfileobj(source, target) + if lifecycle.digest(Path(name)) != sha: + raise ValueError("model checksum mismatch; existing model preserved") + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def validate_request(body, voices): + if ( + not isinstance(body, dict) + or body.get("model") != "kokoro" + or body.get("response_format", "wav") != "wav" + ): + raise ValueError("expected kokoro model and wav format") + text, voice = body.get("input"), body.get("voice") + if not isinstance(text, str) or not text.strip() or len(text) > 12000: + raise ValueError("input must contain 1–12000 characters") + if voice not in voices: + raise ValueError("unknown English voice") + return text.strip(), voice + + +def wav_bytes(samples, rate): + pcm = array.array( + "h", (max(-32768, min(32767, round(float(x) * 32767))) for x in samples) + ) + if sys.byteorder != "little": + pcm.byteswap() + out = io.BytesIO() + with wave.open(out, "wb") as target: + target.setnchannels(1) + target.setsampwidth(2) + target.setframerate(rate) + target.writeframes(pcm.tobytes()) + return out.getvalue() + + +def disconnected(connection): + return ( + bool(select.select([connection], [], [], 0)[0]) + and connection.recv(1, socket.MSG_PEEK) == b"" + ) + + +def text_chunks(text): + # Bound each inference even for punctuation-free text and very long words. + while text: + if len(text) <= 300: + yield text + return + cut = max(text.rfind(mark, 0, 300) + 1 for mark in ".!?\n") + if cut < 80: + cut = text.rfind(" ", 0, 300) + if cut <= 0: + cut = 300 + yield text[:cut] + text = text[cut:].lstrip() + + +async def synthesize(engine, text, voice, connection): + chunks = [] + size = 44 + deadline = time.monotonic() + 55 + for part in text_chunks(text): + if disconnected(connection): + raise ConnectionError("client disconnected") + if time.monotonic() >= deadline: + raise TimeoutError("synthesis deadline exceeded") + samples, rate = await asyncio.to_thread( + engine.create, + part, + voice=voice, + speed=1.0, + lang="en-gb" if voice.startswith("b") else "en-us", + ) + size += len(samples) * 2 + if size > 24 * 1024 * 1024 or rate != 24000: + raise ValueError("synthesis output limit or sample rate invalid") + chunks.append(samples) + if disconnected(connection): + raise ConnectionError("client disconnected") + if time.monotonic() >= deadline: + raise TimeoutError("synthesis deadline exceeded") + return wav_bytes(itertools.chain.from_iterable(chunks), 24000) + + +def serve(state, port, token): + from kokoro_onnx import Kokoro + import onnxruntime as ort + + # Explicit CPU provider avoids device/network auto-discovery. Bound thread use. + options = ort.SessionOptions() + options.intra_op_num_threads = min(4, os.cpu_count() or 1) + session = ort.InferenceSession( + str(state / "kokoro-v1.0.onnx"), + sess_options=options, + providers=["CPUExecutionProvider"], + ) + engine = Kokoro.from_session(session, str(state / "voices-v1.0.bin")) + voices = sorted( + v for v in engine.get_voices() if v.startswith(("af_", "am_", "bf_", "bm_")) + ) + busy = threading.Lock() + stopped = threading.Event() + + class Handler(http.server.BaseHTTPRequestHandler): + def allowed(self): + if self.headers.get("Origin") or self.headers.get("Host") not in ( + f"127.0.0.1:{port}", + f"localhost:{port}", + ): + self.send_error(403) + return False + return True + + def reply(self, code, body, mime="application/json"): + data = body if isinstance(body, bytes) else json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + self.wfile.write(data) + + def do_GET(self): + if not self.allowed(): + return + if self.path in ("/status", "/stop"): + if self.headers.get("Authorization") != "Bearer " + token: + self.send_error(403) + return + self.reply( + 200, + {"token": token, "ready": True, "url": f"http://127.0.0.1:{port}"}, + ) + if self.path == "/stop": + stopped.set() + elif self.path == "/health": + self.reply( + 200, {"status": "ok", "model": "kokoro", "busy": busy.locked()} + ) + elif self.path == "/v1/audio/voices": + self.reply(200, {"voices": voices}) + else: + self.send_error(404) + + def do_POST(self): + if not self.allowed(): + return + if self.path != "/v1/audio/speech": + self.send_error(404) + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 192 * 1024: + self.send_error(413) + return + self.connection.settimeout(5) + text, voice = validate_request( + json.loads(self.rfile.read(length)), voices + ) + except (ValueError, OSError): + self.reply(400, {"error": "invalid bounded Kokoro request"}) + return + if not busy.acquire(blocking=False): + self.reply(429, {"error": "local voice is busy; no request was queued"}) + return + try: + audio = asyncio.run(synthesize(engine, text, voice, self.connection)) + self.reply(200, audio, "audio/wav") + except Exception: + self.reply(503, {"error": "local synthesis failed"}) + finally: + busy.release() + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler) + server.daemon_threads = True + server.timeout = 0.2 + lifecycle.write_json(state / "runtime.json", {"control_port": port, "token": token}) + try: + while not stopped.is_set(): + server.handle_request() + finally: + server.server_close() + if lifecycle.read_json(state / "runtime.json").get("token") == token: + (state / "runtime.json").unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--state-dir", type=Path, default=Path.home() / ".config/agentflow/local-kokoro" + ) + sub = parser.add_subparsers(dest="command", required=True) + setup = sub.add_parser("setup") + setup.add_argument( + "--python", default=sys.executable, help="Python 3.12–3.13 interpreter" + ) + start = sub.add_parser("start") + start.add_argument("--port", type=int, default=17865) + start.add_argument("--timeout", type=float, default=60) + sub.add_parser("status") + sub.add_parser("stop") + internal = sub.add_parser("_serve", help=argparse.SUPPRESS) + internal.add_argument("port", type=int) + args = parser.parse_args() + state = args.state_dir.expanduser().resolve() + state.mkdir(parents=True, exist_ok=True, mode=0o700) + state.chmod(0o700) + if args.command == "_serve": + token = lifecycle.read_supervisor_token(sys.stdin.fileno()) + sys.stdin.close() + serve(state, args.port, token) + return + with (state / "lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + record = lifecycle.read_json(state / "runtime.json") + active = lifecycle.control(record, "status") + python = state / "venv/bin/python" + if args.command == "status": + reason = assets_status(state) + if reason == "ready" and not python.is_file(): + reason = "missing_runtime" + lifecycle.emit( + { + "ready": bool(active), + "owned": bool(active), + "reason": "ready" + if active + else ("stopped" if reason == "ready" else reason), + "url": active.get("url") if active else None, + } + ) + elif args.command == "stop": + result = lifecycle.control(record, "stop") + deadline = time.monotonic() + 7 + while ( + result + and (state / "runtime.json").exists() + and time.monotonic() < deadline + ): + time.sleep(0.05) + if result and (state / "runtime.json").exists(): + raise ValueError("owned runtime did not stop within deadline") + lifecycle.emit({"stopped": bool(result), "ready": False}) + elif args.command == "setup": + if active: + raise ValueError("stop owned runtime before setup") + subprocess.run( + [ + args.python, + "-c", + 'import sys; assert (3,12) <= sys.version_info[:2] <= (3,13), "Use Python 3.12–3.13"', + ], + check=True, + ) + subprocess.run([args.python, "-m", "venv", str(state / "venv")], check=True) + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--require-hashes", + "-r", + str(Path(__file__).with_name("kokoro-requirements.txt")), + ], + check=True, + stdout=sys.stderr, + ) + for name, sha in ASSETS.items(): + if (state / name).is_file() and lifecycle.digest(state / name) == sha: + continue + with urllib.request.urlopen(RELEASE + name, timeout=60) as source: + install_asset(state / name, source, sha) + lifecycle.emit({"installed": True, "state": str(state)}) + elif args.command == "start": + if active: + lifecycle.emit({k: v for k, v in active.items() if k != "token"}) + return + if assets_status(state) != "ready" or not python.is_file(): + raise ValueError( + "missing or corrupt model/runtime; run setup explicitly" + ) + if not 1 <= args.port <= 65535 or not 0 < args.timeout <= 300: + raise ValueError("invalid port or readiness timeout") + with socket.socket() as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind(("127.0.0.1", args.port)) + except OSError: + raise ValueError( + "port already in use; no existing process was stopped" + ) from None + token = secrets.token_hex(32) + child = subprocess.Popen( + [ + str(python), + str(Path(__file__).resolve()), + "--state-dir", + str(state), + "_serve", + str(args.port), + ], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + child.stdin.write((token + "\n").encode()) + child.stdin.close() + deadline = time.monotonic() + args.timeout + while child.poll() is None and time.monotonic() < deadline: + record = lifecycle.read_json(state / "runtime.json") + result = ( + lifecycle.control(record, "status") + if record.get("token") == token + else None + ) + if result: + lifecycle.emit({k: v for k, v in result.items() if k != "token"}) + return + time.sleep(0.1) + if child.poll() is None: + child.terminate() + child.wait(timeout=7) + raise ValueError( + "Kokoro readiness failed; rerun setup to repair runtime/model" + ) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, subprocess.SubprocessError) as error: + lifecycle.emit({"error": str(error)}) + sys.exit(1) diff --git a/scripts/test_local_kokoro.py b/scripts/test_local_kokoro.py new file mode 100644 index 0000000..9f5d682 --- /dev/null +++ b/scripts/test_local_kokoro.py @@ -0,0 +1,454 @@ +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name("local-kokoro.py") + + +class RuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("local_kokoro", SCRIPT) + cls.runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.runtime) + + def test_model_missing_corrupt_and_verified(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) + self.assertEqual(self.runtime.assets_status(p), "missing_model") + for name in self.runtime.ASSETS: + (p / name).write_bytes(b"corrupt") + self.assertEqual(self.runtime.assets_status(p), "corrupt_model") + + def test_verified_assets_and_atomic_install(self): + import hashlib + from unittest.mock import patch + + with tempfile.TemporaryDirectory() as d: + path = Path(d) + checksum = hashlib.sha256(b"verified").hexdigest() + with patch.dict(self.runtime.ASSETS, {"fixture": checksum}, clear=True): + self.runtime.install_asset( + path / "fixture", io.BytesIO(b"verified"), checksum + ) + self.assertEqual(self.runtime.assets_status(path), "ready") + + def test_cli_missing_model_never_starts(self): + import subprocess + import sys + + with tempfile.TemporaryDirectory() as d: + base = [sys.executable, str(SCRIPT), "--state-dir", d] + result = subprocess.run(base + ["status"], capture_output=True, text=True) + self.assertEqual(json.loads(result.stdout)["reason"], "missing_model") + result = subprocess.run(base + ["start"], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertIn("setup explicitly", json.loads(result.stdout)["error"]) + self.assertFalse((Path(d) / "runtime.json").exists()) + + def test_request_rejects_invalid_model_voice_format_and_size(self): + valid = dict( + model="kokoro", + input="Hello world.", + voice="af_heart", + response_format="wav", + ) + self.assertEqual( + self.runtime.validate_request(valid, ["af_heart"]), + ("Hello world.", "af_heart"), + ) + for patch in ( + {"model": "cloud"}, + {"voice": "unknown"}, + {"response_format": "mp3"}, + {"input": ""}, + {"input": "a" * 12001}, + ): + with self.subTest(patch=patch), self.assertRaises(ValueError): + self.runtime.validate_request(valid | patch, ["af_heart"]) + + def test_failed_asset_install_preserves_existing(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "model" + path.write_bytes(b"old") + with self.assertRaises(ValueError): + self.runtime.install_asset(path, io.BytesIO(b"wrong"), "a" * 64) + self.assertEqual(path.read_bytes(), b"old") + + def test_wav_is_pcm_mono(self): + import wave + + data = self.runtime.wav_bytes([0.0, 0.5, -0.5], 24000) + with wave.open(io.BytesIO(data)) as wav: + self.assertEqual( + ( + wav.getnchannels(), + wav.getsampwidth(), + wav.getframerate(), + wav.getnframes(), + ), + (1, 2, 24000, 3), + ) + + +class CancellationTests(unittest.IsolatedAsyncioTestCase): + async def test_disconnect_stops_future_batches(self): + import asyncio + import socket + import importlib.util + + spec = importlib.util.spec_from_file_location("k", SCRIPT) + runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runtime) + + class Engine: + calls = 0 + + def create(self, *args, **kwargs): + self.calls += 1 + return [0.1] * 240, 24000 + + engine = Engine() + a, b = socket.socketpair() + b.close() + try: + with self.assertRaises(ConnectionError): + await runtime.synthesize(engine, "A sentence. " * 100, "af_heart", a) + self.assertEqual(engine.calls, 0) + finally: + a.close() + + async def test_disconnect_during_batch_skips_remaining_text(self): + import asyncio + import socket + import time + + spec = importlib.util.spec_from_file_location("k", SCRIPT) + runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runtime) + a, b = socket.socketpair() + + class Engine: + calls = 0 + + def create(self, *args, **kwargs): + self.calls += 1 + b.close() + time.sleep(0.02) + return [0.1] * 240, 24000 + + engine = Engine() + try: + with self.assertRaises(ConnectionError): + await runtime.synthesize(engine, "A sentence. " * 100, "af_heart", a) + self.assertEqual(engine.calls, 1) + finally: + a.close() + + +class LifecycleTests(unittest.TestCase): + def test_owned_server_http_contract_and_stop(self): + import multiprocessing + import socket + import sys + import time + import types + import urllib.request + import urllib.error + + spec = importlib.util.spec_from_file_location("k", SCRIPT) + runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runtime) + + class Engine: + @classmethod + def from_session(cls, *args): + return cls() + + def get_voices(self): + return ["af_heart", "jf_alpha"] + + def create(self, *args, **kwargs): + return [0.1] * 240, 24000 + + class Options: + pass + + def run(state, port): + sys.modules["kokoro_onnx"] = types.SimpleNamespace(Kokoro=Engine) + sys.modules["onnxruntime"] = types.SimpleNamespace( + SessionOptions=Options, InferenceSession=lambda *a, **k: None + ) + runtime.serve(state, port, "test-token") + + with tempfile.TemporaryDirectory() as d: + state = Path(d) + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + child = multiprocessing.get_context("fork").Process( + target=run, args=(state, port) + ) + child.start() + try: + deadline = time.monotonic() + 5 + while ( + not (state / "runtime.json").exists() + and time.monotonic() < deadline + ): + time.sleep(0.02) + record = runtime.lifecycle.read_json(state / "runtime.json") + self.assertTrue(runtime.lifecycle.control(record, "status")["ready"]) + self.assertIsNone( + runtime.lifecycle.control(dict(record, token="unowned"), "stop") + ) + url = f"http://127.0.0.1:{port}" + self.assertEqual( + runtime.lifecycle.request_local(url + "/v1/audio/voices")["voices"], + ["af_heart"], + ) + req = urllib.request.Request( + url + "/v1/audio/speech", + data=json.dumps( + dict(model="kokoro", input="Test.", voice="af_heart") + ).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as response: + self.assertEqual(response.headers["Content-Type"], "audio/wav") + self.assertTrue(response.read().startswith(b"RIFF")) + # Text length is characters; JSON escaping can require twelve bytes per supplementary character. + req = urllib.request.Request( + url + "/v1/audio/speech", + data=json.dumps( + dict(model="kokoro", input="😀" * 12000, voice="af_heart") + ).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as response: + self.assertEqual(response.status, 200) + self.assertTrue(response.read().startswith(b"RIFF")) + req = urllib.request.Request( + url + "/health", headers={"Origin": "https://evil.test"} + ) + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(req) + self.assertEqual(error.exception.code, 403) + error.exception.close() + runtime.lifecycle.control(record, "stop") + child.join(5) + self.assertFalse(child.is_alive()) + self.assertFalse((state / "runtime.json").exists()) + finally: + if child.is_alive(): + child.terminate() + child.join() + + +class ManagedCliTests(unittest.TestCase): + def setUp(self): + import sys + import hashlib + + self.directory = tempfile.TemporaryDirectory() + self.state = Path(self.directory.name) + self.wrapper = self.state / "venv/bin/python" + self.wrapper.parent.mkdir(parents=True) + self.wrapper.write_text( + "#!" + + sys.executable + + "\n" + + r"""import importlib.util, sys, types, time +from pathlib import Path +spec = importlib.util.spec_from_file_location("runtime", sys.argv[1]) +runtime = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runtime) +runtime.ASSETS = {"fixture": "FIXTURE_SHA"} +class Engine: + @classmethod + def from_session(cls, *args): + state = Path(sys.argv[sys.argv.index("--state-dir")+1]) + if (state/"fail").exists(): + raise ValueError("synthetic load failure") + if (state/"hang").exists(): + time.sleep(30) + return cls() + def get_voices(self): + return ["af_heart"] + def create(self, *args, **kwargs): + return [.1]*240, 24000 +class Options: pass +sys.modules["kokoro_onnx"] = types.SimpleNamespace(Kokoro=Engine) +sys.modules["onnxruntime"] = types.SimpleNamespace(SessionOptions=Options,InferenceSession=lambda *a,**k: None) +sys.argv = sys.argv[1:] +try: + runtime.main() +except (OSError, ValueError) as error: + runtime.lifecycle.emit({"error":str(error)}) + sys.exit(1) +""".replace("FIXTURE_SHA", hashlib.sha256(b"fixture").hexdigest()) + ) + self.wrapper.chmod(0o700) + (self.state / "fixture").write_bytes(b"fixture") + + def command(self, *args): + import subprocess + + result = subprocess.run( + [str(self.wrapper), str(SCRIPT), "--state-dir", str(self.state), *args], + capture_output=True, + text=True, + timeout=15, + ) + return result.returncode, json.loads(result.stdout) + + def free_port(self): + import socket + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + def tearDown(self): + self.command("stop") + self.directory.cleanup() + + def test_start_is_idempotent_and_stop_releases_port(self): + import socket + + port = self.free_port() + first = self.command("start", "--port", str(port)) + self.assertEqual(first[0], 0, first) + second = self.command("start", "--port", str(port)) + self.assertEqual(first, second) + self.assertTrue(self.command("status")[1]["owned"]) + self.assertTrue(self.command("stop")[1]["stopped"]) + self.assertFalse(self.command("stop")[1]["stopped"]) + with socket.socket() as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("127.0.0.1", port)) + + def test_port_collision_preserves_unowned_listener(self): + import socket + + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + code, result = self.command("start", "--port", str(port)) + self.assertEqual(code, 1) + self.assertIn("port already in use", result["error"]) + self.assertFalse(self.command("stop")[1]["stopped"]) + with socket.create_connection(("127.0.0.1", port), timeout=1) as client: + accepted, _ = listener.accept() + accepted.close() + + def test_start_failure_and_timeout_recover(self): + import socket + + port = self.free_port() + for marker in ("fail", "hang"): + with self.subTest(marker=marker): + (self.state / marker).touch() + code, result = self.command( + "start", "--port", str(port), "--timeout", "0.5" + ) + self.assertEqual(code, 1) + self.assertIn("readiness failed", result["error"]) + self.assertFalse(self.command("status")[1]["owned"]) + (self.state / marker).unlink() + code, result = self.command( + "start", "--port", str(port), "--timeout", "5" + ) + self.assertEqual(code, 0, result) + self.assertTrue(result["ready"]) + self.assertTrue(self.command("stop")[1]["stopped"]) + + +class SynthesisBoundsTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + import socket + + spec = importlib.util.spec_from_file_location("k", SCRIPT) + self.runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.runtime) + self.a, self.b = socket.socketpair() + + def tearDown(self): + self.a.close() + self.b.close() + + async def test_long_text_is_complete_and_audio_is_concatenated(self): + import wave + import struct + + class Engine: + def __init__(self): + self.calls = [] + + def create(self, text, **kwargs): + self.calls.append(text) + return [len(self.calls) / 100], 24000 + + engine = Engine() + text = ("The quick brown fox jumps over the lazy dog. " * 200).strip() + data = await self.runtime.synthesize(engine, text, "af_heart", self.a) + self.assertEqual(" ".join(engine.calls), text) + self.assertTrue(all(len(part) <= 300 for part in engine.calls)) + self.assertGreater(len(engine.calls), 1) + with wave.open(io.BytesIO(data)) as audio: + frames = audio.readframes(audio.getnframes()) + self.assertEqual( + list(struct.unpack("<" + "h" * len(engine.calls), frames)), + [round(i / 100 * 32767) for i in range(1, len(engine.calls) + 1)], + ) + + async def test_oversized_audio_stops_before_another_batch(self): + class Oversized: + def __len__(self): + return 24 * 1024 * 1024 + + class Engine: + calls = 0 + + def create(self, *args, **kwargs): + self.calls += 1 + return Oversized(), 24000 + + engine = Engine() + with self.assertRaisesRegex(ValueError, "output limit"): + await self.runtime.synthesize( + engine, "A sentence. " * 100, "af_heart", self.a + ) + self.assertEqual(engine.calls, 1) + + async def test_elapsed_deadline_stops_remaining_batches(self): + from unittest.mock import patch + import types + + elapsed = [0] + + class Engine: + calls = 0 + + def create(self, *args, **kwargs): + self.calls += 1 + elapsed[0] = 56 + return [0.1], 24000 + + engine = Engine() + with patch.object( + self.runtime, "time", types.SimpleNamespace(monotonic=lambda: elapsed[0]) + ): + with self.assertRaisesRegex(TimeoutError, "deadline"): + await self.runtime.synthesize( + engine, "A sentence. " * 100, "af_heart", self.a + ) + self.assertEqual(engine.calls, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cli.ts b/src/cli.ts index dd0fcb5..0f978ec 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -286,25 +286,24 @@ async function mcp() { } async function main() { const [command, ...args] = process.argv.slice(2); - if (command === "local-whisper") { + if (command === "local-whisper" || command === "local-kokoro") { const script = fileURLToPath( - new URL("../scripts/local-whisper.py", import.meta.url), + new URL(`../scripts/${command}.py`, import.meta.url), ); const code = await new Promise((resolve, reject) => { const child = spawn( "python3", [ script, - "--app", - "agentflow", + ...(command === "local-whisper" ? ["--app", "agentflow"] : []), "--state-dir", - join(appHome, "local-whisper"), + join(appHome, command), ...args, ], { stdio: "inherit" }, ); child.on("error", () => - reject(Error("Local Whisper setup requires Python 3 on PATH")), + reject(Error("Local speech setup requires Python 3 on PATH")), ); child.on("close", (code) => resolve(code ?? 1)); }); @@ -360,7 +359,7 @@ async function main() { return; } console.log( - 'Agentflow\n start | open | status\n run codex|claude [--resume ID]\n speak [--speed 0.75–2] --text "Hello" (or pipe text through stdin)\n service install|status|restart|stop|uninstall\n local-whisper setup|import-model|start|status|stop [--help]\n mcp', + 'Agentflow\n start | open | status\n run codex|claude [--resume ID]\n speak [--speed 0.75–2] --text "Hello" (or pipe text through stdin)\n service install|status|restart|stop|uninstall\n local-whisper setup|import-model|start|status|stop [--help]\n local-kokoro setup|start|status|stop [--help]\n mcp', ); } main().catch((e) => { diff --git a/src/server.ts b/src/server.ts index 9f3ad3a..d07f621 100644 --- a/src/server.ts +++ b/src/server.ts @@ -94,7 +94,11 @@ export async function playLocalAudio( ) { const d = await mkdtemp(join(tmpdir(), "agentflow-play-")); try { - const p = join(d, "speech.mp3"); + const isWav = + audio.length >= 12 && + audio.toString("ascii", 0, 4) === "RIFF" && + audio.toString("ascii", 8, 12) === "WAVE"; + const p = join(d, isWav ? "speech.wav" : "speech.mp3"); await writeFile(p, audio, { mode: 0o600 }); await player( "/usr/bin/afplay", @@ -333,7 +337,9 @@ export async function createApp({ res, await transcribe( c, - c.provider === "local-whisper" ? "" : await store.get(c.provider), + c.provider === "local-whisper" || c.provider === "local-kokoro" + ? "" + : await store.get(c.provider), audio, req.headers["content-type"] || "audio/webm", ac.signal, @@ -356,7 +362,9 @@ export async function createApp({ res.on("close", () => ac.abort()); const r = await synthesize( c, - c.provider === "local-whisper" ? "" : await store.get(c.provider), + c.provider === "local-whisper" || c.provider === "local-kokoro" + ? "" + : await store.get(c.provider), string(b.text), ac.signal, ); diff --git a/src/speech.ts b/src/speech.ts index 5e2635c..722e48b 100644 --- a/src/speech.ts +++ b/src/speech.ts @@ -1,6 +1,10 @@ import { localFetch } from "./local-http.js"; import { normalizeLocalAudio } from "./local-audio.js"; -import { record, localWhisperUrl } from "../public/contracts.js"; +import { + record, + localWhisperUrl, + localKokoroUrl, +} from "../public/contracts.js"; import type { SpeechConfig, Provider } from "../public/contracts.js"; export type Fetcher = (url: string, options: RequestInit) => Promise; import { deepgramVoice } from "../public/deepgram-voices.js"; @@ -10,6 +14,7 @@ export const providers: Provider[] = [ "elevenlabs", "9router", "local-whisper", + "local-kokoro", ]; export const defaults = { deepgram: { stt: "nova-3", tts: "aura-2-helena-en", voice: "" }, @@ -19,6 +24,7 @@ export const defaults = { voice: "coral", }, elevenlabs: { stt: "scribe_v2", tts: "eleven_flash_v2_5", voice: "" }, + "local-kokoro": { stt: "", tts: "kokoro", voice: "af_heart" }, "local-whisper": { stt: "base.en", tts: "", voice: "" }, "9router": { stt: "", tts: "", voice: "" }, }; @@ -31,6 +37,14 @@ function config(c: SpeechConfig, key: string) { base: localWhisperUrl(c.baseUrl), headers: {} as Record, }; + if (c.provider === "local-kokoro") { + if (c.model !== "kokoro") + throw failure("invalid_model", "Local Kokoro requires the kokoro model"); + return { + base: localKokoroUrl(c.baseUrl), + headers: {} as Record, + }; + } if (!providers.includes(c.provider)) throw failure("invalid_provider", "Unknown speech provider"); if (!key && c.provider !== "9router") @@ -123,6 +137,11 @@ export async function transcribe( signal?: AbortSignal, fetcher?: Fetcher, ) { + if (c.provider === "local-kokoro") + throw failure( + "invalid_provider", + "Local Kokoro supports speech output only", + ); fetcher ??= c.provider === "local-whisper" ? localFetch : fetch; const { base, headers } = config(c, key); if (!audio?.length || audio.length > 20 * 1024 * 1024) @@ -232,8 +251,9 @@ export async function synthesize( key: string, text: unknown, signal?: AbortSignal, - fetcher: Fetcher = fetch, + fetcher?: Fetcher, ) { + fetcher ??= c.provider === "local-kokoro" ? localFetch : fetch; if (c.provider === "local-whisper") throw failure( "invalid_provider", @@ -263,8 +283,8 @@ export async function synthesize( body = { model: c.model, input: text, - voice: c.voice || "coral", - response_format: "mp3", + voice: c.voice || (c.provider === "local-kokoro" ? "af_heart" : "coral"), + response_format: c.provider === "local-kokoro" ? "wav" : "mp3", }; } const r = await request( @@ -293,6 +313,7 @@ export async function synthesize( const chunks = []; try { while (true) { + signal?.throwIfAborted(); const { done, value } = await reader.read(); if (done) break; size += value.length; @@ -309,8 +330,16 @@ export async function synthesize( "Speech provider returned empty audio", 502, ); + signal?.throwIfAborted(); + const audio = Buffer.concat(chunks); + if (c.provider === "local-kokoro" && !validWav(audio)) + throw failure( + "malformed_response", + "Local Kokoro returned invalid WAV audio", + 502, + ); return { - audio: Buffer.concat(chunks), + audio, mime: mime === "application/octet-stream" ? "audio/mpeg" : mime, }; } @@ -328,3 +357,39 @@ export function previewConfig(config: SpeechConfig, model: unknown) { ); return { ...config, model }; } + +// Validate the complete PCM container before handing local runtime bytes to playback. +function validWav(audio: Buffer): boolean { + if ( + audio.length < 44 || + audio.toString("ascii", 0, 4) !== "RIFF" || + audio.toString("ascii", 8, 12) !== "WAVE" || + audio.readUInt32LE(4) !== audio.length - 8 + ) + return false; + let format = false, + data = false; + for (let offset = 12; offset + 8 <= audio.length;) { + const id = audio.toString("ascii", offset, offset + 4); + const length = audio.readUInt32LE(offset + 4); + const start = offset + 8, + end = start + length; + if (end > audio.length) return false; + if (id === "fmt ") { + if ( + length < 16 || + audio.readUInt16LE(start) !== 1 || + audio.readUInt16LE(start + 2) !== 1 || + audio.readUInt32LE(start + 4) !== 24000 || + audio.readUInt32LE(start + 8) !== 48000 || + audio.readUInt16LE(start + 12) !== 2 || + audio.readUInt16LE(start + 14) !== 16 + ) + return false; + format = true; + } + if (id === "data") data = length > 0 && length % 2 === 0; + offset = end + (length % 2); + } + return format && data; +} diff --git a/src/state.ts b/src/state.ts index 4afa800..66a276d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,6 +1,7 @@ import { record, localWhisperUrl, + localKokoroUrl, isProvider, parseSettings, playbackSpeed, @@ -46,7 +47,10 @@ export class Settings { async public(): Promise { const configured = {} as Record; for (const p of providers) - configured[p] = p === "local-whisper" || !!(await this.store.get(p)); + configured[p] = + p === "local-whisper" || + p === "local-kokoro" || + !!(await this.store.get(p)); return { ...structuredClone(this.value), configured, defaults }; } async update(value: unknown) { @@ -64,10 +68,17 @@ export class Settings { "invalid_provider", "Local Whisper supports transcription only", ); + if (side === "stt" && c.provider === "local-kokoro") + throw failure( + "invalid_provider", + "Local Kokoro supports speech output only", + ); next[side] = { ...(c.provider === "local-whisper" ? { baseUrl: localWhisperUrl(c.baseUrl) } - : {}), + : c.provider === "local-kokoro" + ? { baseUrl: localKokoroUrl(c.baseUrl) } + : {}), provider: c.provider, model: c.model.trim(), voice: typeof c.voice === "string" ? c.voice.slice(0, 200) : "", @@ -97,7 +108,9 @@ export class Settings { baseUrl: this.value[side].provider === "local-whisper" ? localWhisperUrl(this.value[side].baseUrl) - : this.value.routerUrl, + : this.value[side].provider === "local-kokoro" + ? localKokoroUrl(this.value[side].baseUrl) + : this.value.routerUrl, }; } } diff --git a/src/store.ts b/src/store.ts index d0a7554..1e6cd2b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -28,6 +28,7 @@ const envNames = { elevenlabs: "ELEVENLABS_API_KEY", "9router": "NINEROUTER_KEY", "local-whisper": "", + "local-kokoro": "", }; export class KeyStore { cache: Map; @@ -35,7 +36,7 @@ export class KeyStore { this.cache = new Map(); } async get(provider: Provider) { - if (provider === "local-whisper") return ""; + if (provider === "local-whisper" || provider === "local-kokoro") return ""; if (this.cache.has(provider)) return this.cache.get(provider)!; let value = ""; try { diff --git a/test/browser/dashboard.spec.ts b/test/browser/dashboard.spec.ts index eb9e10c..fe2291d 100644 --- a/test/browser/dashboard.spec.ts +++ b/test/browser/dashboard.spec.ts @@ -17,6 +17,7 @@ const settings: PublicSettings = { elevenlabs: false, "9router": false, "local-whisper": true, + "local-kokoro": true, }, defaults: { deepgram: { stt: "nova-3", tts: "aura-2-thalia-en", voice: "" }, @@ -27,6 +28,7 @@ const settings: PublicSettings = { }, elevenlabs: { stt: "scribe_v2", tts: "eleven_flash_v2_5", voice: "" }, "local-whisper": { stt: "base.en", tts: "", voice: "" }, + "local-kokoro": { stt: "", tts: "kokoro", voice: "af_heart" }, "9router": { stt: "", tts: "", voice: "" }, }, }; @@ -547,3 +549,214 @@ test("stale microphone permission rejection cannot stop the current recording", await expect(page.locator("#state")).toHaveText("Listening"); await page.locator("#end").click(); }); + +test("local Kokoro output is independent, keyless and saved with voice and speed", async ({ + page, +}) => { + await mock(page); + await page.goto("/"); + await page.locator("#settingsButton").click(); + await expect( + page.locator('#ttsProvider option[value="local-kokoro"]'), + ).toHaveCount(1); + await expect( + page.locator('#sttProvider option[value="local-kokoro"]'), + ).toHaveCount(0); + await page.locator("#ttsProvider").selectOption("local-kokoro"); + await expect(page.locator("#ttsVoice")).toHaveValue("af_heart"); + await expect(page.getByLabel("Local Kokoro API key")).toHaveCount(0); + await page.locator("#ttsSpeed").selectOption("1.25"); + const saved = page.waitForRequest( + (r) => r.url().endsWith("/api/settings") && r.method() === "PUT", + ); + await page.getByRole("button", { name: "Save preferences" }).click(); + const body = (await saved).postDataJSON(); + expect(body.stt.provider).toBe("deepgram"); + expect(body.tts).toMatchObject({ + provider: "local-kokoro", + model: "kokoro", + voice: "af_heart", + speed: 1.25, + baseUrl: "http://127.0.0.1:17865", + }); + await expect(page.locator("#ttsLabel")).toContainText("Local Kokoro speaks"); + await page.locator("#testVoice").click(); + await expect(page.locator("#state")).not.toHaveText("Error"); +}); + +test("voice test can be interrupted during synthesis and cannot play a late response", async ({ + page, +}) => { + await mock(page); + let release!: () => void; + const held = new Promise((r) => { + release = r; + }); + await page.route("**/api/synthesize", async (route) => { + await held; + await route + .fulfill({ contentType: "audio/wav", body: wav() }) + .catch(() => {}); + }); + await page.addInitScript(() => { + Object.defineProperty(window, "playedCount", { value: { count: 0 } }); + HTMLMediaElement.prototype.play = function () { + (window as unknown as { playedCount: { count: number } }).playedCount + .count++; + return Promise.resolve(); + }; + }); + await page.goto("/"); + const pending = page.waitForRequest("**/api/synthesize"); + await page.locator("#testVoice").click(); + await pending; + await expect(page.locator("#interrupt")).toBeEnabled(); + await page.locator("#interrupt").click(); + release(); + await expect(page.locator("#state")).toHaveText("Interrupted"); + expect( + await page.evaluate( + () => + (window as unknown as { playedCount: { count: number } }).playedCount + .count, + ), + ).toBe(0); +}); + +test("local voice playback pauses within 250ms and replay never dispatches a new turn", async ({ + page, +}) => { + await mock(page); + let turns = 0; + page.on("request", (r) => { + if (r.url().endsWith("/api/turns")) turns++; + }); + const long = Buffer.alloc(44 + 160000); + wav().copy(long, 0, 0, 44); + long.writeUInt32LE(long.length - 8, 4); + long.writeUInt32LE(long.length - 44, 40); + for (let i = 44; i < long.length; i += 2) + long.writeInt16LE( + Math.round(1000 * Math.sin((((i - 44) / 2) * Math.PI * 2 * 440) / 8000)), + i, + ); + await page.route("**/api/synthesize", (r) => + r.fulfill({ contentType: "audio/wav", body: long }), + ); + await page.addInitScript(() => { + const play = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function () { + Object.assign(window, { testAudio: this }); + return play.call(this); + }; + }); + await page.goto("/"); + await page.locator("#settingsButton").click(); + await page.locator("#ttsProvider").selectOption("local-kokoro"); + await page.getByRole("button", { name: "Save preferences" }).click(); + await page.getByRole("button", { name: /Native sync test/ }).click(); + await page.getByRole("button", { name: "Show transcript" }).click(); + await page.locator("#replay").click(); + await expect + .poll(() => + page.evaluate(() => { + const a = (window as unknown as { testAudio?: HTMLAudioElement }) + .testAudio; + return Boolean(a && !a.paused && a.currentTime > 0); + }), + ) + .toBe(true); + const elapsed = await page.evaluate(() => { + const a = (window as unknown as { testAudio: HTMLAudioElement }).testAudio; + const start = performance.now(); + document.getElementById("interrupt")!.click(); + if (!a.paused) throw Error("Playback did not pause synchronously"); + return performance.now() - start; + }); + expect(elapsed).toBeLessThanOrEqual(250); + console.log("Local playback interrupt ms:", elapsed); + expect(turns).toBe(0); +}); + +test("interrupting replay preserves Interrupted after a delayed synthesis response", async ({ + page, +}) => { + await mock(page); + let release!: () => void; + const held = new Promise((r) => { + release = r; + }); + await page.route("**/api/synthesize", async (route) => { + await held; + await route + .fulfill({ contentType: "audio/wav", body: wav() }) + .catch(() => {}); + }); + await page.goto("/"); + await page.getByRole("button", { name: /Native sync test/ }).click(); + await page.getByRole("button", { name: "Show transcript" }).click(); + const pending = page.waitForRequest("**/api/synthesize"); + await page.locator("#replay").click(); + await pending; + await page.locator("#interrupt").click(); + release(); + await expect(page.locator("#state")).toHaveText("Interrupted"); +}); + +test("failed speech keeps the error and explicit replay retries ordered sentences without another agent turn", async ({ + page, +}) => { + await mock(page); + await page.addInitScript(() => { + HTMLMediaElement.prototype.play = function () { + queueMicrotask(() => this.dispatchEvent(new Event("ended"))); + return Promise.resolve(); + }; + }); + let fail = true, + turns = 0; + const spoken: string[] = []; + page.on("request", (r) => { + if (r.url().endsWith("/api/turns")) turns++; + }); + await page.route("**/api/jobs/*", (r) => + r.fulfill({ + json: { + id: "fixture", + agent: "codex", + sessionId: id, + cwd: "/tmp/test-project", + text: "hello", + status: "complete", + reply: "First sentence. Second sentence.", + events: [], + createdAt: "2026-09-19T00:00:00Z", + }, + }), + ); + await page.route("**/api/synthesize", (r) => { + spoken.push(r.request().postDataJSON().text); + return fail + ? r.fulfill({ + status: 503, + json: { error: "Local engine is unavailable" }, + }) + : r.fulfill({ contentType: "audio/wav", body: wav() }); + }); + await page.goto("/"); + await page.getByRole("button", { name: /Native sync test/ }).click(); + await page.locator("#message").fill("hello"); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.locator("#state")).toHaveText("Let’s try that again"); + expect(spoken).toEqual(["First sentence."]); + fail = false; + await page.getByRole("button", { name: "Show transcript" }).click(); + await page.locator("#replay").click(); + await expect(page.locator("#hint")).toHaveText("Replay complete."); + expect(spoken).toEqual([ + "First sentence.", + "First sentence.", + "Second sentence.", + ]); + expect(turns).toBe(1); +}); diff --git a/test/build.test.ts b/test/build.test.ts index a089901..1c556cb 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -24,3 +24,12 @@ test("built local-whisper CLI exposes setup help without starting services", asy assert.match(stdout, /import-model/); assert.match(stdout, /setup/); }); +test("built local-kokoro CLI exposes explicit lifecycle help without starting services", async () => { + const { stdout } = await promisify(execFile)(process.execPath, [ + "dist/src/cli.js", + "local-kokoro", + "--help", + ]); + assert.match(stdout, /setup/); + assert.match(stdout, /state-dir/); +}); diff --git a/test/local-kokoro.test.ts b/test/local-kokoro.test.ts new file mode 100644 index 0000000..f71fb56 --- /dev/null +++ b/test/local-kokoro.test.ts @@ -0,0 +1,292 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Settings } from "../src/state.js"; +import { synthesize, transcribe } from "../src/speech.js"; +import { parseSettings, type SpeechConfig } from "../public/contracts.js"; +const local = { + provider: "local-kokoro", + model: "kokoro", + voice: "af_heart", + baseUrl: "http://127.0.0.1:17865", +} as unknown as SpeechConfig; +function wav() { + const b = Buffer.alloc(46); + b.write("RIFF"); + b.writeUInt32LE(38, 4); + b.write("WAVEfmt ", 8); + b.writeUInt32LE(16, 16); + b.writeUInt16LE(1, 20); + b.writeUInt16LE(1, 22); + b.writeUInt32LE(24000, 24); + b.writeUInt32LE(48000, 28); + b.writeUInt16LE(2, 32); + b.writeUInt16LE(16, 34); + b.write("data", 36); + b.writeUInt32LE(2, 40); + return b; +} +test("local Kokoro persists independently and requires no credentials", async () => { + const dir = await mkdtemp(join(tmpdir(), "af-kokoro-")); + try { + const store = { + get: async (p: string) => { + assert.ok(!p.startsWith("local-")); + return ""; + }, + }; + const s = new Settings(store, dir); + const stt = structuredClone(s.value.stt); + await s.update({ tts: { ...local, speed: 1.5 } }); + const restored = new Settings(store, dir); + await restored.init(); + assert.deepEqual(await restored.speech("tts"), { ...local, speed: 1.5 }); + assert.deepEqual(restored.value.stt, stt); + await assert.rejects(() => s.update({ stt: local }), /speech output only/i); + assert.throws( + () => parseSettings({ ...s.value, stt: local }), + /speech output only/i, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); +test("local Kokoro sends WAV request without credentials or synthesis speed", async () => { + const result = await synthesize( + { ...local, speed: 1.5 }, + "DO_NOT_SEND", + "Hello.", + undefined, + async (url, options) => { + assert.equal(url, local.baseUrl + "/v1/audio/speech"); + assert.equal(new Headers(options.headers).get("authorization"), null); + assert.equal(options.redirect, "error"); + assert.deepEqual(JSON.parse(String(options.body)), { + model: "kokoro", + input: "Hello.", + voice: "af_heart", + response_format: "wav", + }); + return new Response(wav(), { headers: { "content-type": "audio/wav" } }); + }, + ); + assert.equal(result.mime, "audio/wav"); + assert.deepEqual(result.audio, wav()); +}); +test("local Kokoro rejects remote endpoints and STT before transport", async () => { + for (const baseUrl of [ + "https://example.com", + "http://localhost.evil", + "http://x:y@127.0.0.1", + "http://127.0.0.1/path", + "http://127.0.0.1?key=x", + ]) + await assert.rejects( + () => synthesize({ ...local, baseUrl }, "", "hello"), + /loopback/i, + ); + await assert.rejects( + () => transcribe(local, "", Buffer.from("audio")), + /speech output only/i, + ); +}); +test("local Kokoro rejects malformed WAV and canceled late audio", async () => { + for (const bytes of [Buffer.from("not audio"), wav().subarray(0, 44)]) { + await assert.rejects( + () => + synthesize( + local, + "", + "hello", + undefined, + async () => + new Response(bytes, { headers: { "content-type": "audio/wav" } }), + ), + /invalid WAV/i, + ); + } + const c = new AbortController(); + await assert.rejects(() => + synthesize(local, "", "hello", c.signal, async () => { + c.abort(); + return new Response(wav(), { headers: { "content-type": "audio/wav" } }); + }), + ); +}); +test("local Kokoro bounds output and normalizes runtime failure without response text", async () => { + await assert.rejects( + () => + synthesize( + local, + "", + "hello", + undefined, + async () => + new Response(Buffer.alloc(24 * 1024 * 1024 + 1), { + headers: { "content-type": "audio/wav" }, + }), + ), + /exceeded 24 MB/, + ); + await assert.rejects( + () => + synthesize( + local, + "", + "hello", + undefined, + async () => new Response("private model path", { status: 503 }), + ), + (e) => { + assert.ok(e instanceof Error); + assert.doesNotMatch(e.message, /private model path/); + return true; + }, + ); +}); +test("real local Kokoro transport bypasses global fetch and refuses redirects", async () => { + const { createServer } = await import("node:http"); + let count = 0; + const server = createServer(async (req, res) => { + count++; + assert.equal(req.url, "/v1/audio/speech"); + assert.equal(req.headers.authorization, undefined); + for await (const chunk of req) { + } + if (count === 1) { + res.setHeader("content-type", "audio/wav"); + res.end(wav()); + } else { + res.writeHead(302, { location: "http://127.0.0.1:1/forbidden" }); + res.end(); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const a = server.address(); + assert.ok(a && typeof a !== "string"); + const saved = globalThis.fetch; + try { + globalThis.fetch = async () => { + throw Error("proxy must not receive speech"); + }; + const config = { ...local, baseUrl: `http://127.0.0.1:${a.port}` }; + assert.deepEqual( + (await synthesize(config, "SECRET", "hello")).audio, + wav(), + ); + await assert.rejects(() => synthesize(config, "", "hello"), /HTTP 302/); + assert.equal(count, 2); + } finally { + globalThis.fetch = saved; + await new Promise((r) => server.close(() => r())); + } +}); +test("HTTP synthesis uses local Kokoro without any key lookup", async () => { + const { createServer } = await import("node:http"); + const { createApp } = await import("../src/server.js"); + const engine = createServer(async (req, res) => { + for await (const chunk of req) { + } + res.setHeader("content-type", "audio/wav"); + res.end(wav()); + }); + await new Promise((r) => engine.listen(0, "127.0.0.1", r)); + const a = engine.address(); + assert.ok(a && typeof a !== "string"); + const dir = await mkdtemp(join(tmpdir(), "af-kokoro-http-")); + let reads = 0; + const app = await createApp({ + port: 0, + dir, + store: { + get: async () => { + reads++; + throw Error("no key lookup"); + }, + }, + agents: { init: async () => {}, close() {} }, + }); + try { + app.settings.value.tts = { + ...local, + baseUrl: `http://127.0.0.1:${a.port}`, + }; + const { token } = await (await fetch(app.url + "/api/bootstrap")).json(); + const r = await fetch(app.url + "/api/synthesize", { + method: "POST", + headers: { + "x-agentflow-token": token, + "content-type": "application/json", + }, + body: JSON.stringify({ text: "Hello locally." }), + }); + assert.equal(r.status, 200); + assert.equal(reads, 0); + assert.deepEqual(Buffer.from(await r.arrayBuffer()), wav()); + } finally { + await app.close(); + await new Promise((r) => engine.close(() => r())); + await rm(dir, { recursive: true, force: true }); + } +}); +test("canceling a stalled real local audio response closes transport and permits a new request", async () => { + const { createServer } = await import("node:http"); + let first = true; + let closed!: () => void; + const disconnected = new Promise((r) => { + closed = r; + }); + let started!: () => void; + const headersSent = new Promise((r) => { + started = r; + }); + const engine = createServer(async (req, res) => { + for await (const chunk of req) { + } + res.setHeader("content-type", "audio/wav"); + if (first) { + first = false; + res.write(wav().subarray(0, 20)); + res.on("close", closed); + started(); + } else res.end(wav()); + }); + await new Promise((r) => engine.listen(0, "127.0.0.1", r)); + const address = engine.address(); + assert.ok(address && typeof address !== "string"); + const config = { ...local, baseUrl: `http://127.0.0.1:${address.port}` }; + try { + const controller = new AbortController(); + const pending = synthesize(config, "", "hello", controller.signal); + const rejection = assert.rejects(pending); + await headersSent; + controller.abort(); + await rejection; + await disconnected; + assert.deepEqual((await synthesize(config, "", "next")).audio, wav()); + } finally { + engine.closeAllConnections(); + await new Promise((r) => engine.close(() => r())); + } +}); +test("rejected local endpoint settings leave persisted and active selections unchanged", async () => { + const dir = await mkdtemp(join(tmpdir(), "af-kokoro-invalid-")); + const store = { get: async () => "" }; + try { + const s = new Settings(store, dir); + await s.update({ tts: local }); + const saved = structuredClone(s.value); + await assert.rejects( + () => s.update({ tts: { ...local, baseUrl: "https://cloud.example" } }), + /loopback/, + ); + assert.deepEqual(s.value, saved); + const restored = new Settings(store, dir); + await restored.init(); + assert.deepEqual(restored.value, saved); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/playback-speed.test.ts b/test/playback-speed.test.ts index ed2ea1a..114593d 100644 --- a/test/playback-speed.test.ts +++ b/test/playback-speed.test.ts @@ -69,3 +69,32 @@ test("local speak sends the saved playback speed to afplay without changing synt ); assert.ok(!request.includes("speed")); }); + +test("local playback preserves WAV extension and keeps MPEG playback unchanged, then deletes audio", async () => { + const { readFile, access } = await import("node:fs/promises"); + const wav = Buffer.alloc(46); + wav.write("RIFF"); + wav.writeUInt32LE(38, 4); + wav.write("WAVEfmt ", 8); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(24000, 24); + wav.writeUInt32LE(48000, 28); + wav.writeUInt16LE(2, 32); + wav.writeUInt16LE(16, 34); + wav.write("data", 36); + wav.writeUInt32LE(2, 40); + for (const [bytes, extension] of [ + [wav, ".wav"], + [Buffer.from("ID3"), ".mp3"], + ] as const) { + let path = ""; + await playLocalAudio(bytes, 1.25, async (_executable, args) => { + path = args.at(-1)!; + assert.ok(path.endsWith(extension), path); + assert.deepEqual(await readFile(path), bytes); + }); + await assert.rejects(() => access(path)); + } +}); diff --git a/test/voice.test.ts b/test/voice.test.ts index e077f0d..29a0403 100644 --- a/test/voice.test.ts +++ b/test/voice.test.ts @@ -17,3 +17,24 @@ test("voice gate submits speech after silence, never silence alone", () => { assert.equal(gate.sample(0, 5800), false); assert.equal(gate.sample(0, 6600), true); }); +test("bounded speech chunks preserve long replies, whitespace and Unicode in order", async () => { + const voice = await import("../public/voice.js"); + const split = ( + voice as unknown as { speechChunks: (text: string) => string[] } + ).speechChunks; + assert.equal(typeof split, "function"); + for (const text of [ + "Hello.", + "word ".repeat(6000), + "x".repeat(25000), + "x".repeat(11999) + "😀" + "tail".repeat(4000), + ]) { + const chunks = split(text); + assert.equal(chunks.join(""), text); + assert.ok(chunks.every((c) => c.length > 0 && c.length <= 12000)); + assert.ok(chunks.every((c) => !/[\uD800-\uDBFF]$/.test(c))); + } + assert.deepEqual(split(""), []); + const words = split("word ".repeat(6000)); + assert.ok(words.slice(0, -1).every((c) => /\s$/.test(c))); +});