refactor(huddle): switch local TTS to Pocket TTS (Kyutai/sherpa-onnx) (#607)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-18 14:28:27 -04:00
committed by GitHub
parent 137a4e268f
commit c0eb5affcf
14 changed files with 1991 additions and 1466 deletions

View File

@@ -66,13 +66,13 @@ const overrides = new Map([
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
["src-tauri/src/huddle/models.rs", 900], // model download manager for Parakeet TDT-CTC STT + Kokoro TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + CC-BY-4.0 attribution sidecar + idempotent legacy Moonshine dir cleanup
["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block
["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription
["src-tauri/src/huddle/pocket.rs", 900], // Pocket TTS engine wrapper + prepare_pocket_prompt (capitalize/punctuate/pad short inputs + insert ". . " sacrificial cold-start prefix, mirror upstream pocket-tts prepare_text_prompt) + build_generation_extra (only overrides max_frames, and only for ≤4-word inputs, to bound runaway "monster breathing" generation without clipping multi-clause sentences) + trim_leading_cold_start (post-synth strip of sacrificial audio on short prompts, workaround for kyutai-labs/pocket-tts #91 first-phoneme drop) + 18 unit tests including regressions for the static-burst-on-multi-clause-sentence bug and the first-phoneme-drop trim hazards
["src-tauri/src/huddle/preprocessing.rs", 670], // TTS text preprocessing pipeline + unified split_sentences + int_to_words 0-999999 + URL trailing punctuation preservation + 23 unit tests
["src-tauri/src/huddle/relay_api.rs", 520], // audio relay recv task + per-peer frame counting for remote human TTS interrupt + NIP-98 channel member query
["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations

View File

@@ -736,9 +736,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.60"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -956,7 +956,7 @@ dependencies = [
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -1066,25 +1066,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1236,16 +1217,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "der"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
dependencies = [
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1341,7 +1312,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1474,12 +1445,6 @@ dependencies = [
"libm",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.8"
@@ -1560,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1660,15 +1625,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1676,7 +1632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -1690,12 +1646,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2199,9 +2149,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.17.0"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@@ -2251,12 +2201,6 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "hmac-sha256"
version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2563,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -2812,9 +2756,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.185"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libdbus-sys"
@@ -2896,12 +2840,6 @@ dependencies = [
"crc",
]
[[package]]
name = "lzma-rust2"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
[[package]]
name = "lzma-sys"
version = "0.1.20"
@@ -2988,16 +2926,6 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -3064,40 +2992,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
"rayon",
"windows-sys 0.60.2",
]
[[package]]
@@ -3608,50 +3503,12 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3687,30 +3544,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "ort"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"ndarray",
"ort-sys",
"smallvec",
"tracing",
"ureq 3.3.0",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
dependencies = [
"hmac-sha256",
"lzma-rust2",
"ureq 3.3.0",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -3806,15 +3639,6 @@ dependencies = [
"hmac",
]
[[package]]
name = "pem-rfc7468"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -4087,21 +3911,6 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -4457,32 +4266,6 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "realfft"
version = "3.5.0"
@@ -4777,7 +4560,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4810,9 +4593,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
@@ -4836,7 +4619,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5312,7 +5095,7 @@ checksum = "384b71e9586b28493902080ed89e9b4457d2c684521d4c10df62c381b6fdcb51"
dependencies = [
"bzip2 0.4.4",
"tar",
"ureq 2.12.1",
"ureq",
]
[[package]]
@@ -5368,18 +5151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
"windows-sys 0.60.2",
]
[[package]]
@@ -5446,11 +5218,9 @@ dependencies = [
"hex",
"infer",
"libc",
"ndarray",
"nostr 0.36.0",
"nostr 0.37.0",
"opus",
"ort",
"png 0.18.1",
"regex",
"reqwest 0.13.2",
@@ -6369,7 +6139,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6792,7 +6562,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6858,7 +6628,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6967,36 +6737,6 @@ dependencies = [
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"der",
"log",
"native-tls",
"percent-encoding",
"rustls-pki-types",
"socks",
"ureq-proto",
"utf8-zero",
"webpki-root-certs",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -7028,12 +6768,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -7052,12 +6786,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -7134,11 +6862,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[package]]
@@ -7147,7 +6875,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.51.0",
]
[[package]]
@@ -7330,9 +7058,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
@@ -7413,7 +7141,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7942,6 +7670,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"

View File

@@ -63,8 +63,6 @@ uuid = { version = "1", features = ["v4"] }
png = "0.18"
zip = "2"
sherpa-onnx = "1.12"
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "download-binaries", "tls-native", "copy-dylibs", "api-23", "coreml"] }
ndarray = { version = "0.17", features = ["rayon"] }
regex = "1"
axum = "0.8"
rodio = "0.22"

View File

@@ -0,0 +1,117 @@
//! Cold-vs-warm latency bench for Pocket TTS.
//!
//! This duplicates the small config-building snippet from `huddle::pocket` so it
//! doesn't depend on changing module visibility for a one-off dev tool.
//! Keep in sync with `huddle::pocket::load_text_to_speech`.
//!
//! Run with the model files in a directory (defaults to /tmp/pocket-tts-bench):
//! cargo run --release --example pocket_bench
//! cargo run --release --example pocket_bench /path/to/pocket-tts
use std::path::PathBuf;
use std::time::Instant;
use sherpa_onnx::{
self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
OfflineTtsPocketModelConfig, Wave,
};
const SAMPLE_RATE: u32 = 24_000;
const TEST_TEXT: &str =
"Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.";
fn main() {
let model_dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string());
println!("Model dir: {model_dir}");
let dir = PathBuf::from(&model_dir);
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
let t0 = Instant::now();
let mut cfg = OfflineTtsConfig::default();
cfg.model = OfflineTtsModelConfig {
pocket: OfflineTtsPocketModelConfig {
lm_main: Some(p("lm_main.int8.onnx")),
lm_flow: Some(p("lm_flow.int8.onnx")),
encoder: Some(p("encoder.onnx")),
decoder: Some(p("decoder.int8.onnx")),
text_conditioner: Some(p("text_conditioner.onnx")),
vocab_json: Some(p("vocab.json")),
token_scores_json: Some(p("token_scores.json")),
voice_embedding_cache_capacity: 16,
},
num_threads: 1,
debug: false,
..Default::default()
};
let engine = OfflineTts::create(&cfg).expect("engine create");
let load_ms = t0.elapsed().as_secs_f32() * 1000.0;
println!("Engine load: {load_ms:.1} ms");
let t0 = Instant::now();
let voice_path = dir.join("reference_sample.wav");
let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
let samples = wave.samples().to_vec();
let sr = wave.sample_rate();
let voice_ms = t0.elapsed().as_secs_f32() * 1000.0;
println!("Voice load: {voice_ms:.1} ms");
let gen = || GenerationConfig {
speed: 1.05,
num_steps: 1,
silence_scale: 0.0,
reference_audio: Some(samples.clone()),
reference_sample_rate: sr,
..Default::default()
};
// Cold synth — first call after engine creation.
let t0 = Instant::now();
let cold = engine
.generate_with_config(TEST_TEXT, &gen(), None::<fn(&[f32], f32) -> bool>)
.expect("cold synth");
let cold_ms = t0.elapsed().as_secs_f32() * 1000.0;
let cold_audio_ms = (cold.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0;
let cold_rtf_x = cold_audio_ms / cold_ms;
println!(
"Cold synth: {cold_ms:.1} ms → {cold_audio_ms:.1} ms audio → {cold_rtf_x:.2}× realtime"
);
// Warm synth — second call.
let t0 = Instant::now();
let warm = engine
.generate_with_config(TEST_TEXT, &gen(), None::<fn(&[f32], f32) -> bool>)
.expect("warm synth");
let warm_ms = t0.elapsed().as_secs_f32() * 1000.0;
let warm_audio_ms = (warm.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0;
let warm_rtf_x = warm_audio_ms / warm_ms;
println!(
"Warm synth: {warm_ms:.1} ms → {warm_audio_ms:.1} ms audio → {warm_rtf_x:.2}× realtime"
);
// Write warm output for listening.
let out_path = "/tmp/pocket_bench_out.wav";
let ok = sherpa_onnx::write(out_path, warm.samples(), SAMPLE_RATE as i32);
println!(
"Wrote {} ({} samples, ok={ok})",
out_path,
warm.samples().len()
);
let delta_ms = cold_ms - warm_ms;
let delta_pct = (delta_ms / warm_ms) * 100.0;
println!();
println!("Cold/warm delta: {delta_ms:+.1} ms ({delta_pct:+.1}%)");
println!(
"Decision: warmup {}.",
if delta_ms > 200.0 {
"RECOMMENDED — significant cold-call penalty"
} else if delta_ms > 50.0 {
"OPTIONAL — small cold-call penalty"
} else {
"UNNECESSARY — cold and warm essentially equal"
}
);
}

View File

@@ -0,0 +1,148 @@
//! Onset-attenuation probe for Pocket TTS.
//!
//! Synthesises a handful of short sentences and dumps per-sentence onset
//! statistics (samples[0], 1ms/5ms/20ms peak + RMS) so we can decide whether
//! the production `apply_fades` 8 ms fade-in is masking real audio.
//!
//! Also writes the raw (un-faded, un-normalised) audio of each sentence to
//! /tmp so they can be inspected in Audacity / aplay without rodio in the
//! loop.
//!
//! Run with model files in /tmp/pocket-tts-bench (override with arg 1):
//! cargo run --release --example pocket_onset_probe
//! cargo run --release --example pocket_onset_probe /path/to/pocket-tts
use std::path::PathBuf;
use sherpa_onnx::{
self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
OfflineTtsPocketModelConfig, Wave,
};
const SAMPLE_RATE: u32 = 24_000;
/// Test prompts chosen to span different onsets:
/// - palatal glide 'Y' (soft onset)
/// - voiceless fricative 'H' (very soft onset)
/// - labio-velar glide 'W' (medium onset)
/// - voiceless stop 'T' (hard onset)
const PROMPTS: &[&str] = &[
"Yep, I can hear you.",
"Hello there friend.",
"What can I help with?",
"Try this experiment now.",
];
fn main() {
let model_dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string());
eprintln!("Model dir: {model_dir}");
let dir = PathBuf::from(&model_dir);
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
let mut cfg = OfflineTtsConfig::default();
cfg.model = OfflineTtsModelConfig {
pocket: OfflineTtsPocketModelConfig {
lm_main: Some(p("lm_main.int8.onnx")),
lm_flow: Some(p("lm_flow.int8.onnx")),
encoder: Some(p("encoder.onnx")),
decoder: Some(p("decoder.int8.onnx")),
text_conditioner: Some(p("text_conditioner.onnx")),
vocab_json: Some(p("vocab.json")),
token_scores_json: Some(p("token_scores.json")),
voice_embedding_cache_capacity: 16,
},
num_threads: 1,
debug: false,
..Default::default()
};
let engine = OfflineTts::create(&cfg).expect("engine create");
let voice_path = dir.join("reference_sample.wav");
let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
let voice_samples = wave.samples().to_vec();
let voice_sr = wave.sample_rate();
// Warmup so we're not measuring cold-call jitter.
{
let cfg = GenerationConfig {
speed: 1.05,
num_steps: 1,
silence_scale: 0.0,
reference_audio: Some(voice_samples.clone()),
reference_sample_rate: voice_sr,
..Default::default()
};
let _ = engine.generate_with_config("warmup.", &cfg, None::<fn(&[f32], f32) -> bool>);
}
println!(
"{:<28} | {:>10} | {:>10} {:>10} | {:>10} {:>10} | {:>10} {:>10}",
"prompt",
"samples[0]",
"peak@1ms",
"rms@1ms",
"peak@5ms",
"rms@5ms",
"peak@20ms",
"rms@20ms"
);
println!("{}", "-".repeat(120));
for prompt in PROMPTS {
// Mirror the production prompt-prep (capitalise + terminal punctuation).
// These prompts already have it, so this is just to match what
// sherpa-onnx sees in production.
let cfg = GenerationConfig {
speed: 1.05,
num_steps: 1,
silence_scale: 0.0,
reference_audio: Some(voice_samples.clone()),
reference_sample_rate: voice_sr,
..Default::default()
};
let out = engine
.generate_with_config(prompt, &cfg, None::<fn(&[f32], f32) -> bool>)
.expect("synth");
let samples = out.samples();
let n_1ms = (SAMPLE_RATE as f32 * 0.001) as usize;
let n_5ms = (SAMPLE_RATE as f32 * 0.005) as usize;
let n_20ms = (SAMPLE_RATE as f32 * 0.020) as usize;
let stats = |range: &[f32]| -> (f32, f32) {
if range.is_empty() {
return (0.0, 0.0);
}
let peak = range.iter().fold(0.0_f32, |a, &x| a.max(x.abs()));
let sumsq: f32 = range.iter().map(|x| x * x).sum();
let rms = (sumsq / range.len() as f32).sqrt();
(peak, rms)
};
let first = samples.first().copied().unwrap_or(0.0);
let (p1, r1) = stats(&samples[..n_1ms.min(samples.len())]);
let (p5, r5) = stats(&samples[..n_5ms.min(samples.len())]);
let (p20, r20) = stats(&samples[..n_20ms.min(samples.len())]);
println!(
"{:<28} | {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6}",
prompt, first, p1, r1, p5, r5, p20, r20
);
// Dump raw WAV for inspection.
let safe: String = prompt
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let out_path = format!("/tmp/pocket_onset_{}.wav", &safe[..safe.len().min(24)]);
let _ = sherpa_onnx::write(&out_path, samples, SAMPLE_RATE as i32);
eprintln!(
" → wrote {out_path} ({} samples = {:.3} s)",
samples.len(),
samples.len() as f32 / SAMPLE_RATE as f32
);
}
}

View File

@@ -0,0 +1,218 @@
//! Reproduction probe for the Pocket TTS first-phoneme-drop fix.
//!
//! Pocket TTS' FlowLM has an autoregressive cold-start that occasionally
//! smears or drops the first phoneme of short utterances — see
//! kyutai-labs/pocket-tts #91, #70 and sherpa-onnx #3180. The cure is to
//! prepend a sacrificial `". . "` cold-start absorber to short prompts
//! and trim the resulting leading audio. This example reproduces both
//! variants of generated audio so you can listen-test the fix at the
//! exact `GenerationConfig` we ship in production (`huddle::pocket`):
//!
//! - silence_scale: 0.0 (production)
//! - max_frames: 100 (short) / sherpa default 500 (long)
//! - num_steps: 1
//! - speed: 1.05
//!
//! Note: production does NOT override `frames_after_eos` — sherpa-onnx's
//! default of 3 is what we want. The previous attempt to override it for
//! long prompts caused the "first 'yep' is static" regression (commit
//! 1dbfa2c). This probe mirrors that decision.
//!
//! Run:
//! cargo run --release --example prod_probe
//! cargo run --release --example prod_probe /path/to/pocket-tts
//!
//! Output (per (label, seed) pair):
//! /tmp/prod_<label>_s<seed>_raw.wav — raw engine output
//! /tmp/prod_<label>_s<seed>_trimmed.wav — post-trim (what production
//! ships, for `_sac` labels)
//!
//! The "no sacrificial" variants have no trim applied (just _raw); they
//! show what production produces for long prompts. The "_sac" variants
//! show both raw and trimmed, which is what `huddle::pocket::synth_chunk`
//! returns for short prompts. Listen back with `afplay`.
use std::collections::HashMap;
use std::path::PathBuf;
use sherpa_onnx::{
self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
OfflineTtsPocketModelConfig, Wave,
};
const SAMPLE_RATE: u32 = 24_000;
const SHORT_PROMPT_MAX_FRAMES: i32 = 100;
// Mirror of huddle::pocket trim constants so the probe stays in sync with
// production. If you change either side, change both.
const TRIM_SCAN_START_SAMPLES: usize = (SAMPLE_RATE as usize * 30) / 1000;
const TRIM_SILENCE_THRESHOLD: f32 = 0.02;
const TRIM_MIN_GAP_SAMPLES: usize = (SAMPLE_RATE as usize * 50) / 1000;
const TRIM_MAX_DROP_SAMPLES: usize = (SAMPLE_RATE as usize * 1200) / 1000;
/// Mirror of `huddle::pocket::trim_leading_cold_start` — keep in sync.
fn trim_leading_cold_start(samples: &mut Vec<f32>) {
if samples.len() <= TRIM_SCAN_START_SAMPLES {
return;
}
let mut silence_run_start: Option<usize> = None;
let mut gap_end: Option<usize> = None;
for (i, sample) in samples.iter().enumerate().skip(TRIM_SCAN_START_SAMPLES) {
if sample.abs() < TRIM_SILENCE_THRESHOLD {
silence_run_start.get_or_insert(i);
} else if let Some(start) = silence_run_start {
if i - start >= TRIM_MIN_GAP_SAMPLES {
gap_end = Some(i);
break;
}
silence_run_start = None;
}
}
let Some(end) = gap_end else {
return;
};
if end > TRIM_MAX_DROP_SAMPLES {
return;
}
samples.drain(..end);
}
// (label, raw_text_before_prep, sacrificial_prefix_to_add_after_pad)
const TESTS: &[(&str, &str, &str)] = &[
// Short, previously-failing — sacrificial applied
("imhappy_sac", "I'm happy.", ". . "),
("imsorry_sac", "I'm sorry.", ". . "),
("imready_sac", "I'm ready.", ". . "),
// Short, previously-OK — sacrificial applied
("yep_sac", "Yep.", ". . "),
("isee_sac", "I see you.", ". . "),
// Long — sacrificial NOT applied (per design)
("longer_nosac", "Hello, how can I help you today?", ""),
("multi_nosac", "Yes, that works. Let me try again.", ""),
];
fn main() {
let model_dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string());
let dir = PathBuf::from(&model_dir);
let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
let mut cfg = OfflineTtsConfig::default();
cfg.model = OfflineTtsModelConfig {
pocket: OfflineTtsPocketModelConfig {
lm_main: Some(p("lm_main.int8.onnx")),
lm_flow: Some(p("lm_flow.int8.onnx")),
encoder: Some(p("encoder.onnx")),
decoder: Some(p("decoder.int8.onnx")),
text_conditioner: Some(p("text_conditioner.onnx")),
vocab_json: Some(p("vocab.json")),
token_scores_json: Some(p("token_scores.json")),
voice_embedding_cache_capacity: 16,
},
num_threads: 1,
debug: false,
..Default::default()
};
let engine = OfflineTts::create(&cfg).expect("engine create");
let voice_path = dir.join("reference_sample.wav");
let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
let ref_samples = wave.samples().to_vec();
let ref_sr = wave.sample_rate();
let seeds: &[i32] = &[42, 1337, 99999, 7, 314159];
println!(
"{:18} | {:>6} | {:>5} | {:>7} | gap_search (50ms,0.02) | path",
"test", "seed", "kind", "len_ms"
);
println!("{}", "-".repeat(110));
for (label, raw_text, sacrificial) in TESTS {
// Mirror huddle::pocket::prepare_pocket_prompt:
// - cleaned text starts with capital, ends with punctuation
// - short (≤4 words) → pad + sacrificial; max_frames=100
// - long → unchanged; no max_frames override
let cleaned = raw_text.trim();
let word_count = cleaned.split_whitespace().count();
let is_short = word_count <= 4;
let pad = if is_short { " " } else { "" };
let prompt = format!("{pad}{sacrificial}{cleaned}");
// Only short prompts get the post-synth trim in production; long
// prompts pass through unmodified.
let trim_in_production = is_short && !sacrificial.is_empty();
for seed in seeds {
let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
extra.insert("seed".to_string(), serde_json::Value::from(*seed));
if is_short {
extra.insert(
"max_frames".to_string(),
serde_json::Value::from(SHORT_PROMPT_MAX_FRAMES),
);
}
let gen = GenerationConfig {
speed: 1.05,
num_steps: 1,
silence_scale: 0.0, // PRODUCTION SETTING
reference_audio: Some(ref_samples.clone()),
reference_sample_rate: ref_sr,
extra: Some(extra),
..Default::default()
};
let audio = engine
.generate_with_config(&prompt, &gen, None::<fn(&[f32], f32) -> bool>)
.expect("synth");
// Raw output (what the engine returned).
let raw_samples = audio.samples().to_vec();
let raw_ms = raw_samples.len() as f32 / SAMPLE_RATE as f32 * 1000.0;
let raw_gap = find_gap(&raw_samples, SAMPLE_RATE, 0.02, 50);
let raw_path = format!("/tmp/prod_{}_s{}_raw.wav", label, seed);
sherpa_onnx::write(&raw_path, &raw_samples, SAMPLE_RATE as i32);
println!(
"{:18} | {:>6} | {:>5} | {:>5.0}ms | {:>22} | {}",
label, seed, "raw", raw_ms, raw_gap, raw_path
);
// Trimmed output — what synth_chunk actually returns to tts.rs
// for short prompts. For long prompts the engine output is
// returned untrimmed, so we skip writing a separate file.
if trim_in_production {
let mut trimmed = raw_samples;
trim_leading_cold_start(&mut trimmed);
let trimmed_ms = trimmed.len() as f32 / SAMPLE_RATE as f32 * 1000.0;
let trimmed_path = format!("/tmp/prod_{}_s{}_trimmed.wav", label, seed);
sherpa_onnx::write(&trimmed_path, &trimmed, SAMPLE_RATE as i32);
println!(
"{:18} | {:>6} | {:>5} | {:>5.0}ms | {:>22} | {}",
label, seed, "trim", trimmed_ms, "(post-trim)", trimmed_path
);
}
}
println!();
}
}
fn find_gap(samples: &[f32], sr: u32, thresh: f32, min_ms: u32) -> String {
let scan_start = (sr as usize * 30) / 1000;
let min_samples = (sr as usize * min_ms as usize) / 1000;
let mut silence_from: Option<usize> = None;
for i in scan_start..samples.len() {
if samples[i].abs() < thresh {
silence_from.get_or_insert(i);
} else if let Some(start) = silence_from {
if i - start >= min_samples {
return format!(
"{:.0}..{:.0}ms ({:.0}ms)",
start as f32 / sr as f32 * 1000.0,
i as f32 / sr as f32 * 1000.0,
(i - start) as f32 / sr as f32 * 1000.0
);
}
silence_from = None;
}
}
"<no gap>".to_string()
}

View File

@@ -1,973 +0,0 @@
//! Kokoro-82M ONNX TTS engine — single-session inference with IPA G2P.
//!
//! Mental model:
//!
//! load_text_to_speech(model_dir) → KokoroTTS
//! load_voice_style(path) → VoiceStyle
//! tts.synth_chunk(text, lang, &style, steps, speed) → Vec<f32> @ 24 kHz
//!
//! ┌──────────┐ G2P ┌──────────┐ tokenize ┌──────────┐
//! │ raw text │ ──────→ │ IPA str │ ─────────→ │ int64[] │
//! └──────────┘ lexicon └──────────┘ 115-char └────┬─────┘
//! │
//! ┌──────────┐ style ┌──────────┐ ONNX ┌────▼─────┐
//! │ .bin file│ ──────→ │ [1, 256] │ ─────────→ │ Vec<f32> │
//! └──────────┘ indexed └──────────┘ session └──────────┘
//! by token count 24 kHz PCM
//!
//! G2P strategy: dictionary lookup (us_gold.json, Apache-2.0 via misaki).
//! OOV words are spelled letter-by-letter using a static IPA table.
//! No espeak dependency — fully GPL-free.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use ndarray::{Array1, Array2};
use ort::{session::Session, value::Value};
// ── Public constants ──────────────────────────────────────────────────────────
pub const SAMPLE_RATE: u32 = 24_000;
pub const DEFAULT_VOICE: &str = "af_heart";
// Maximum phoneme tokens before padding (model context = 512, minus 2 pad tokens).
const MAX_PHONEME_TOKENS: usize = 510;
// ── VoiceStyle ────────────────────────────────────────────────────────────────
/// Raw f32 voice embedding loaded from a `<name>.bin` file.
///
/// The binary is a flat array of shape `[-1, 256]` in row-major order.
/// Row `i` is the style vector for an utterance with `i` phoneme tokens.
/// This encodes both speaker identity and sequence-length-dependent prosody.
#[derive(Debug)]
pub struct VoiceStyle {
data: Vec<f32>, // flat: row i = data[i*256 .. (i+1)*256]
}
impl VoiceStyle {
/// Return the 256-dim style vector for a given phoneme token count.
/// Clamps to the last available row if `token_count` is out of range.
fn get(&self, token_count: usize) -> &[f32] {
let max_rows = self.data.len() / 256;
let idx = token_count.min(max_rows.saturating_sub(1));
&self.data[idx * 256..(idx + 1) * 256]
}
}
/// Load a voice style from a raw little-endian f32 binary file.
pub fn load_voice_style(path: &Path) -> Result<VoiceStyle, String> {
let bytes = fs::read(path).map_err(|e| format!("read voice {}: {e}", path.display()))?;
if bytes.len() % 4 != 0 {
return Err(format!(
"voice file {} has non-multiple-of-4 byte count ({})",
path.display(),
bytes.len()
));
}
let data: Vec<f32> = bytes
.chunks_exact(4)
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
.collect();
if data.len() < 256 {
return Err(format!(
"voice file {} too small ({} floats, need at least 256)",
path.display(),
data.len()
));
}
if data.len() % 256 != 0 {
return Err(format!(
"voice style has {} floats — expected a multiple of 256 (got {} remainder)",
data.len(),
data.len() % 256,
));
}
Ok(VoiceStyle { data })
}
// ── Tokenizer ─────────────────────────────────────────────────────────────────
/// Static 115-entry IPA char → int64 lookup table.
/// IDs are non-contiguous (0–177); unknown chars are silently dropped.
/// Pad token '$' = 0 is prepended and appended to every sequence.
fn build_vocab() -> HashMap<char, i64> {
// Source: onnx-community/Kokoro-82M-v1.0-ONNX tokenizer.json
#[rustfmt::skip]
let entries: &[(char, i64)] = &[
('$', 0),
(';', 1), (':', 2), (',', 3), ('.', 4), ('!', 5), ('?', 6),
('—', 9), ('…', 10), ('"', 11), ('(', 12), (')', 13), ('\u{201c}', 14), ('\u{201d}', 15),
(' ', 16), ('\u{0303}', 17),
('ʣ', 18), ('ʥ', 19), ('ʦ', 20), ('ʨ', 21), ('ᵝ', 22), ('ꭧ', 23),
('A', 24), ('I', 25), ('O', 31), ('Q', 33), ('S', 35), ('T', 36),
('W', 39), ('Y', 41), ('ᵊ', 42),
('a', 43), ('b', 44), ('c', 45), ('d', 46), ('e', 47), ('f', 48),
('h', 50), ('i', 51), ('j', 52), ('k', 53), ('l', 54), ('m', 55),
('n', 56), ('o', 57), ('p', 58), ('q', 59), ('r', 60), ('s', 61),
('t', 62), ('u', 63), ('v', 64), ('w', 65), ('x', 66), ('y', 67), ('z', 68),
('ɑ', 69), ('ɐ', 70), ('ɒ', 71), ('æ', 72), ('β', 75), ('ɔ', 76),
('ɕ', 77), ('ç', 78), ('ɖ', 80), ('ð', 81), ('ʤ', 82), ('ə', 83),
('ɚ', 85), ('ɛ', 86), ('ɜ', 87), ('ɟ', 90), ('ɡ', 92), ('ɥ', 99),
('ɨ', 101), ('ɪ', 102), ('ʝ', 103), ('ɯ', 110), ('ɰ', 111),
('ŋ', 112), ('ɳ', 113), ('ɲ', 114), ('ɴ', 115), ('ø', 116),
('ɸ', 118), ('θ', 119), ('œ', 120), ('ɹ', 123), ('ɾ', 125),
('ɻ', 126), ('ʁ', 128), ('ɽ', 129), ('ʂ', 130), ('ʃ', 131),
('ʈ', 132), ('ʧ', 133), ('ʊ', 135), ('ʋ', 136), ('ʌ', 138),
('ɣ', 139), ('ɤ', 140), ('χ', 142), ('ʎ', 143), ('ʒ', 147),
('ʔ', 148), ('ˈ', 156), ('ˌ', 157), ('ː', 158), ('ʰ', 162),
('ʲ', 164), ('↓', 169), ('→', 171), ('↗', 172), ('↘', 173), ('ᵻ', 177),
];
entries.iter().copied().collect()
}
/// Convert an IPA phoneme string to a padded int64 token sequence.
/// Returns `[0, id1, id2, ..., idN, 0]` clamped to MAX_PHONEME_TOKENS+2.
/// The pre-pad token count (ids.len() - 2) is used to index the style vector.
fn tokenize(phonemes: &str, vocab: &HashMap<char, i64>) -> Vec<i64> {
let mut ids: Vec<i64> = vec![0]; // BOS pad
for id in phonemes
.chars()
.filter_map(|c| vocab.get(&c).copied())
.take(MAX_PHONEME_TOKENS)
{
ids.push(id);
}
ids.push(0); // EOS pad
ids
}
// ── G2P Lexicon ───────────────────────────────────────────────────────────────
/// Grapheme-to-phoneme engine with a four-tier fallback chain:
///
/// 1. Misaki gold+silver dicts (183K words, Kokoro-native IPA)
/// 2. CMUdict (135K words, ARPAbet→Kokoro IPA) — covers inflected forms
/// 3. Morphological suffix stripping (-s/-ed/-ing) + retry tiers 1-2
/// 4. Letter-by-letter spelling
///
/// All dictionaries are Apache-2.0 or BSD licensed. No GPL.
struct Lexicon {
/// Misaki gold+silver merged dictionary (Kokoro-native IPA).
misaki: HashMap<String, String>,
/// CMU Pronouncing Dictionary (ARPAbet converted to Kokoro IPA at load time).
cmudict: HashMap<String, String>,
}
/// IPA pronunciations for individual letter names (used for OOV words).
fn letter_ipa(c: char) -> &'static str {
match c {
'a' => "ˈeɪ",
'b' => "bˈiː",
'c' => "sˈiː",
'd' => "dˈiː",
'e' => "ˈiː",
'f' => "ˈɛf",
'g' => "dʒˈiː",
'h' => "ˈeɪtʃ",
'i' => "ˈaɪ",
'j' => "dʒˈeɪ",
'k' => "kˈeɪ",
'l' => "ˈɛl",
'm' => "ˈɛm",
'n' => "ˈɛn",
'o' => "ˈoʊ",
'p' => "pˈiː",
'q' => "kjˈuː",
'r' => "ˈɑːɹ",
's' => "ˈɛs",
't' => "tˈiː",
'u' => "jˈuː",
'v' => "vˈiː",
'w' => "dˈʌbəljˌuː",
'x' => "ˈɛks",
'y' => "wˈaɪ",
'z' => "zˈiː",
_ => "",
}
}
/// Punctuation chars that are valid Kokoro vocab tokens and should pass through.
fn is_passthrough_punct(c: char) -> bool {
matches!(c, ';' | ':' | ',' | '.' | '!' | '?' | '—' | '…' | ' ')
}
/// Vowels that trigger US English /t/→/ɾ/ flapping (misaki's US_TAUS).
const US_TAUS: &str = "AIOWYiuæɑəɛɪɹʊʌ";
/// ARPAbet → Kokoro IPA conversion. Stress digit is stripped before lookup.
fn arpabet_to_ipa(phoneme: &str) -> &'static str {
match phoneme {
"AA" => "ɑ",
"AE" => "æ",
"AH" => "ʌ",
"AO" => "ɔ",
"AW" => "W",
"AY" => "I",
"EH" => "ɛ",
"ER" => "ɜɹ",
"EY" => "A",
"IH" => "ɪ",
"IY" => "i",
"OW" => "O",
"OY" => "Y",
"UH" => "ʊ",
"UW" => "u",
"B" => "b",
"CH" => "ʧ",
"D" => "d",
"DH" => "ð",
"F" => "f",
"G" => "ɡ",
"HH" => "h",
"JH" => "ʤ",
"K" => "k",
"L" => "l",
"M" => "m",
"N" => "n",
"NG" => "ŋ",
"P" => "p",
"R" => "ɹ",
"S" => "s",
"SH" => "ʃ",
"T" => "t",
"TH" => "θ",
"V" => "v",
"W" => "w",
"Y" => "j",
"Z" => "z",
"ZH" => "ʒ",
_ => "",
}
}
/// Convert a CMUdict ARPAbet pronunciation line to Kokoro IPA.
/// Input: "K R IY0 EY1 T AH0 D" → Output: "kɹiˈAtəd"
fn arpabet_line_to_ipa(arpabet: &str) -> String {
let mut out = String::new();
for token in arpabet.split_whitespace() {
// Split phoneme from stress digit (e.g., "EY1" → "EY", Some('1'))
let (base, stress) = if token.ends_with(|c: char| c.is_ascii_digit()) {
(&token[..token.len() - 1], token.as_bytes().last().copied())
} else {
(token, None)
};
// Stress marker goes BEFORE the vowel's IPA
match stress {
Some(b'1') => out.push('ˈ'), // primary
Some(b'2') => out.push('ˌ'), // secondary
_ => {}
}
// AH with stress=0 is schwa (ə), not ʌ
if base == "AH" && stress == Some(b'0') {
out.push('ə');
} else if base == "ER" && stress == Some(b'0') {
// Unstressed ER is just əɹ
out.push_str("əɹ");
} else {
out.push_str(arpabet_to_ipa(base));
}
}
out
}
impl Lexicon {
/// Load misaki gold+silver dicts and CMUdict.
fn load(gold_path: &Path, silver_path: &Path, cmudict_path: &Path) -> Result<Self, String> {
let mut misaki = Self::load_json(silver_path)?;
let gold = Self::load_json(gold_path)?;
misaki.extend(gold);
let cmudict = if cmudict_path.exists() {
Self::load_cmudict(cmudict_path)?
} else {
eprintln!(
"sprout-desktop: CMUdict not found at {} — inflected forms may be spelled out",
cmudict_path.display()
);
HashMap::new()
};
eprintln!(
"sprout-desktop: G2P loaded — misaki: {} words, cmudict: {} words",
misaki.len(),
cmudict.len()
);
Ok(Lexicon { misaki, cmudict })
}
fn load_json(path: &Path) -> Result<HashMap<String, String>, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let raw: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("parse {}: {e}", path.display()))?;
let obj = raw
.as_object()
.ok_or_else(|| format!("{}: expected JSON object", path.display()))?;
let mut dict = HashMap::with_capacity(obj.len());
for (word, val) in obj {
let ipa = match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(m) => m
.get("DEFAULT")
.or_else(|| m.values().next())
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => continue,
};
if !ipa.is_empty() {
dict.insert(word.to_lowercase(), ipa);
}
}
Ok(dict)
}
/// Load CMUdict and convert ARPAbet → Kokoro IPA at load time.
/// Format: "WORD PH1 PH2 PH3\n" (single space between word and phonemes).
/// Variant pronunciations like "WORD(2)" are skipped — we take the first.
fn load_cmudict(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("read cmudict: {e}"))?;
let mut dict = HashMap::with_capacity(140_000);
for line in content.lines() {
// Skip comments and blank lines
if line.starts_with(";;;") || line.is_empty() {
continue;
}
// Split on first space
let (word, phonemes) = match line.find(' ') {
Some(i) => (&line[..i], line[i + 1..].trim()),
None => continue,
};
// Skip variant pronunciations like "WORD(2)"
if word.contains('(') {
continue;
}
let key = word.to_lowercase();
let ipa = arpabet_line_to_ipa(phonemes);
if !ipa.is_empty() {
dict.entry(key).or_insert(ipa);
}
}
Ok(dict)
}
/// Look up a word across all tiers. Returns None if not found anywhere.
fn lookup(&self, word: &str) -> Option<String> {
self.misaki
.get(word)
.cloned()
.or_else(|| self.cmudict.get(word).cloned())
}
/// Apply English -s/-es/-ies suffix phoneme rules (misaki's `_s`).
fn apply_s(stem_ipa: &str) -> String {
let last = stem_ipa.chars().last().unwrap_or(' ');
if "ptkfθ".contains(last) {
format!("{stem_ipa}s")
} else if "szʃʒʧʤ".contains(last) {
format!("{stem_ipa}ᵻz")
} else {
format!("{stem_ipa}z")
}
}
/// Apply English -ed suffix phoneme rules (misaki's `_ed`).
fn apply_ed(stem_ipa: &str) -> String {
let chars: Vec<char> = stem_ipa.chars().collect();
let last = *chars.last().unwrap_or(&' ');
if "pkfθʃsʧ".contains(last) {
format!("{stem_ipa}t")
} else if last == 'd' {
format!("{stem_ipa}ᵻd")
} else if last != 't' {
format!("{stem_ipa}d")
} else if chars.len() >= 2 && US_TAUS.contains(chars[chars.len() - 2]) {
// US flap: "created" → kɹiˈAɾᵻd
let mut out: String = chars[..chars.len() - 1].iter().collect();
out.push_str("ɾᵻd");
out
} else {
format!("{stem_ipa}ᵻd")
}
}
/// Apply English -ing suffix phoneme rules (misaki's `_ing`).
fn apply_ing(stem_ipa: &str) -> String {
let chars: Vec<char> = stem_ipa.chars().collect();
let last = *chars.last().unwrap_or(&' ');
if last == 't' && chars.len() >= 2 && US_TAUS.contains(chars[chars.len() - 2]) {
// US flap: "creating" → kɹiˈAɾɪŋ
let mut out: String = chars[..chars.len() - 1].iter().collect();
out.push_str("ɾɪŋ");
out
} else {
format!("{stem_ipa}ɪŋ")
}
}
/// Try stripping -s/-ed/-ing suffix, look up the base, and re-apply phonetically.
fn try_morphological(&self, word: &str) -> Option<String> {
// Try -s / -es / -ies
if word.len() >= 3 && word.ends_with('s') {
// -ies → base + y
if word.len() > 4 && word.ends_with("ies") {
if let Some(stem) = self.lookup(&format!("{}y", &word[..word.len() - 3])) {
return Some(Self::apply_s(&stem));
}
}
// -es → base
if word.len() > 4 && word.ends_with("es") && !word.ends_with("ies") {
if let Some(stem) = self.lookup(&word[..word.len() - 2]) {
return Some(Self::apply_s(&stem));
}
}
// -s → base
if !word.ends_with("ss") {
if let Some(stem) = self.lookup(&word[..word.len() - 1]) {
return Some(Self::apply_s(&stem));
}
}
}
// Try -ed / -d
if word.len() >= 4 && word.ends_with('d') {
// -ed → base (not -eed)
if word.len() > 4 && word.ends_with("ed") && !word.ends_with("eed") {
if let Some(stem) = self.lookup(&word[..word.len() - 2]) {
return Some(Self::apply_ed(&stem));
}
// -ed where base ends in e: "created" → "create"
if let Some(stem) = self.lookup(&format!("{}e", &word[..word.len() - 2])) {
return Some(Self::apply_ed(&stem));
}
}
// -d → base (e.g., "discovered" → strip "d" → "discovere" fails,
// but "configured" → strip "d" → "configure" works)
if !word.ends_with("dd") {
if let Some(stem) = self.lookup(&word[..word.len() - 1]) {
return Some(Self::apply_ed(&stem));
}
}
}
// Try -ing
if word.len() >= 5 && word.ends_with("ing") {
let base = &word[..word.len() - 3];
// -ing → base (e.g., "running" base = "runn" — won't match, need double-consonant)
if let Some(stem) = self.lookup(base) {
return Some(Self::apply_ing(&stem));
}
// -ing + e → base+e (e.g., "creating" → "creat" + "e" = "create")
if let Some(stem) = self.lookup(&format!("{base}e")) {
return Some(Self::apply_ing(&stem));
}
// Double consonant: "running" → "run"
if base.len() >= 2 {
let bytes = base.as_bytes();
if bytes[bytes.len() - 1] == bytes[bytes.len() - 2] {
if let Some(stem) = self.lookup(&base[..base.len() - 1]) {
return Some(Self::apply_ing(&stem));
}
}
}
}
None
}
/// Convert a single word to IPA using the full fallback chain.
fn word_to_ipa(&self, word: &str) -> String {
// Compound words: split on hyphens and underscores, process each part
// independently. "short-and-natural" → "short" + "and" + "natural",
// "parent_event_id" → "parent" + "event" + "id".
// Each part gets full dict lookup. Joined with a space (brief TTS pause).
if word.contains('-') || word.contains('_') {
let parts: Vec<String> = word
.split(|c: char| c == '-' || c == '_')
.filter(|p| !p.is_empty())
.map(|p| self.word_to_ipa(p))
.collect();
return parts.join(" ");
}
// Normalize curly quotes to straight apostrophes.
let normalized = word.replace('\u{2019}', "'").replace('\u{2018}', "'");
let stripped: String = normalized
.chars()
.filter(|c| c.is_alphabetic() || *c == '\'')
.collect::<String>()
.to_lowercase();
// Tier 1+2: misaki + CMUdict direct lookup
if let Some(ipa) = self.lookup(&stripped) {
return ipa;
}
// Contractions: "don't" → "don" + "'t"
if let Some(apos_idx) = stripped.find('\'') {
let base = &stripped[..apos_idx];
let suffix = &stripped[apos_idx..];
if let Some(base_ipa) = self.lookup(base) {
let suffix_ipa = self.lookup(suffix).unwrap_or_else(|| match suffix {
"'ve" => "v".to_string(),
"'re" => "ɹ".to_string(),
_ => String::new(),
});
if !suffix_ipa.is_empty() {
return format!("{base_ipa}{suffix_ipa}");
}
}
}
// Tier 3: morphological suffix stripping
if let Some(ipa) = self.try_morphological(&stripped) {
return ipa;
}
// Tier 4: letter-by-letter spelling
stripped
.chars()
.filter(|c| c.is_alphabetic())
.map(letter_ipa)
.collect()
}
/// Convert a full text chunk to an IPA phoneme string.
fn text_to_ipa(&self, text: &str) -> String {
let mut out = String::new();
for token in text.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
let leading: String = token
.chars()
.take_while(|c| is_passthrough_punct(*c))
.collect();
let trailing: String = token
.chars()
.rev()
.take_while(|c| is_passthrough_punct(*c))
.collect::<String>()
.chars()
.rev()
.collect();
let word = &token[leading.len()..token.len() - trailing.len()];
out.push_str(&leading);
if !word.is_empty() {
out.push_str(&self.word_to_ipa(word));
}
out.push_str(&trailing);
}
out
}
}
// ── KokoroTTS ─────────────────────────────────────────────────────────────────
pub struct KokoroTTS {
session: Session,
vocab: HashMap<char, i64>,
lexicon: Lexicon,
// Retained for potential future use (e.g., hot-reloading voices by path).
#[allow(dead_code)]
model_dir: PathBuf,
}
/// Load the Kokoro TTS engine from a model directory.
///
/// Expects:
/// `<model_dir>/model.onnx` (or model_quantized.onnx — tries both)
/// `<model_dir>/us_gold.json` (G2P dictionary)
///
/// CoreML execution provider is registered with auto-fallback to CPU.
/// The compiled CoreML model is cached in `<model_dir>/.coreml_cache/`.
pub fn load_text_to_speech(model_dir: &str) -> Result<KokoroTTS, String> {
let model_dir_path = PathBuf::from(model_dir);
// Try quantized model first for speed, fall back to full-precision.
let model_path = ["model_quantized.onnx", "model_q8f16.onnx", "model.onnx"]
.iter()
.map(|name| model_dir_path.join(name))
.find(|p| p.exists())
.ok_or_else(|| format!("no model.onnx found in {model_dir}"))?;
// ── Session threading options ────────────────────────────────────────
//
// parallel_execution — runs independent graph operators concurrently.
// Kokoro's graph is mostly sequential, so the benefit is modest, but
// it's safe and free to enable.
// intra_threads(0) — lets ONNX Runtime use all available CPU cores for
// parallelism within individual operators (e.g., large matmuls).
//
// NOTE: GraphOptimizationLevel::All is already the ort default — no
// need to set it explicitly. Accuracy-altering flags (approximate_gelu,
// flush_to_zero) are intentionally omitted — they need A/B audio
// validation before enabling for a TTS model. memory_pattern is omitted
// because Kokoro input lengths vary per sentence and the ORT docs warn
// against it for variable-size inputs.
// Try CoreML first (zero binary cost — macOS system framework).
// If the model has ops CoreML can't handle (common with quantized models),
// the EP registers fine but commit_from_file fails. Catch that and retry
// with CPU-only. This is the expected path for model_q8f16.onnx.
let session = {
let mut builder_with_coreml = Session::builder()
.map_err(|e| format!("session builder: {e}"))?
.with_parallel_execution(true)
.map_err(|e| format!("parallel execution: {e}"))?
.with_intra_threads(0)
.map_err(|e| format!("intra threads: {e}"))?
.with_execution_providers([ort::ep::CoreML::default()
.with_compute_units(ort::ep::coreml::ComputeUnits::All)
.with_model_format(ort::ep::coreml::ModelFormat::MLProgram)
.with_model_cache_dir(model_dir_path.join(".coreml_cache").to_string_lossy())
.build()])
.map_err(|e| format!("execution provider: {e}"))?;
match builder_with_coreml.commit_from_file(&model_path) {
Ok(s) => {
eprintln!("sprout-desktop: Kokoro loaded with CoreML acceleration");
s
}
Err(coreml_err) => {
eprintln!(
"sprout-desktop: CoreML failed for {}, falling back to CPU: {coreml_err}",
model_path.display()
);
// Retry without any execution providers — pure CPU.
Session::builder()
.map_err(|e| format!("session builder (CPU fallback): {e}"))?
.with_parallel_execution(true)
.map_err(|e| format!("parallel execution (CPU): {e}"))?
.with_intra_threads(0)
.map_err(|e| format!("intra threads (CPU): {e}"))?
.commit_from_file(&model_path)
.map_err(|e| format!("load model {} (CPU): {e}", model_path.display()))?
}
}
};
let gold_path = model_dir_path.join("us_gold.json");
let silver_path = model_dir_path.join("us_silver.json");
let cmudict_path = model_dir_path.join("cmudict.dict");
let lexicon = Lexicon::load(&gold_path, &silver_path, &cmudict_path)?;
Ok(KokoroTTS {
session,
vocab: build_vocab(),
lexicon,
model_dir: model_dir_path,
})
}
impl KokoroTTS {
/// Synthesize a single pre-split text chunk. Caller is responsible for sentence splitting.
/// This avoids double-splitting when the TTS pipeline has already split the text.
///
/// - `_lang` is accepted for API compatibility but currently unused
/// (Kokoro v1.0 language is selected by voice name prefix, e.g. `af_*`).
/// - `_steps` is accepted for API compatibility but currently unused
/// (Kokoro is not diffusion-based).
pub fn synth_chunk(
&mut self,
text: &str,
_lang: &str,
style: &VoiceStyle,
_steps: usize,
speed: f32,
) -> Result<Vec<f32>, String> {
// G2P: text → IPA phoneme string
let ipa = self.lexicon.text_to_ipa(text);
// Tokenize: IPA → int64 ids with BOS/EOS pad tokens
let token_ids = tokenize(&ipa, &self.vocab);
// Style vector is indexed by phoneme count (excluding the 2 pad tokens).
// Shape expected by model: [1, 256] (kokoro-js uses [1, 256], not [1, 1, 256]).
let phoneme_count = token_ids.len() - 2;
let style_slice = style.get(phoneme_count);
// Build ONNX input tensors.
let seq_len = token_ids.len();
let input_ids_arr = Array2::from_shape_vec((1, seq_len), token_ids)
.map_err(|e| format!("input_ids shape: {e}"))?;
let input_ids_val =
Value::from_array(input_ids_arr).map_err(|e| format!("input_ids Value: {e}"))?;
// Style: [1, 256]. Research notes [1, 1, 256] but kokoro-js uses [1, 256].
let style_arr = Array2::from_shape_vec((1, 256), style_slice.to_vec())
.map_err(|e| format!("style shape: {e}"))?;
let style_val = Value::from_array(style_arr).map_err(|e| format!("style Value: {e}"))?;
let speed_arr = Array1::from_vec(vec![speed]);
let speed_val = Value::from_array(speed_arr).map_err(|e| format!("speed Value: {e}"))?;
// Run inference. Output[0] = waveform float32[1, N_samples].
let outputs = self
.session
.run(ort::inputs! {
"input_ids" => &input_ids_val,
"style" => &style_val,
"speed" => &speed_val,
})
.map_err(|e| format!("onnx run: {e}"))?;
let (_, waveform) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|e| format!("extract waveform: {e}"))?;
Ok(waveform.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Tokenizer ─────────────────────────────────────────────────────────
#[test]
fn tokenize_empty_produces_bos_eos() {
let vocab = build_vocab();
let ids = tokenize("", &vocab);
assert_eq!(ids, vec![0, 0]); // BOS + EOS only
}
#[test]
fn tokenize_known_chars() {
let vocab = build_vocab();
let ids = tokenize("a", &vocab);
// 'a' maps to 43 in the vocab
assert_eq!(ids, vec![0, 43, 0]);
}
#[test]
fn tokenize_unknown_chars_dropped() {
let vocab = build_vocab();
let ids = tokenize("🎉", &vocab);
// Emoji not in vocab — should be dropped, leaving only BOS+EOS
assert_eq!(ids, vec![0, 0]);
}
#[test]
fn tokenize_respects_max_length() {
let vocab = build_vocab();
let long_input: String = "a".repeat(600); // exceeds MAX_PHONEME_TOKENS (510)
let ids = tokenize(&long_input, &vocab);
// Should be clamped: BOS + 510 tokens + EOS = 512
assert_eq!(ids.len(), 512);
assert_eq!(ids[0], 0); // BOS
assert_eq!(*ids.last().unwrap(), 0); // EOS
}
// ── ARPAbet conversion ────────────────────────────────────────────────
#[test]
fn arpabet_simple_word() {
// "HH AH0 L OW1" = hello
let ipa = arpabet_line_to_ipa("HH AH0 L OW1");
assert_eq!(ipa, "həlˈO");
}
#[test]
fn arpabet_stress_markers() {
// Primary stress before vowel, secondary stress before vowel
let ipa = arpabet_line_to_ipa("K R IY0 EY1 T");
// IY0 = unstressed 'i', EY1 = primary 'A'
assert!(ipa.contains('ˈ'), "should contain primary stress: {ipa}");
}
#[test]
fn arpabet_schwa() {
// AH0 should produce schwa (ə), not ʌ
let ipa = arpabet_line_to_ipa("AH0");
assert_eq!(ipa, "ə");
}
#[test]
fn arpabet_unstressed_er() {
// ER0 should produce əɹ
let ipa = arpabet_line_to_ipa("ER0");
assert_eq!(ipa, "əɹ");
}
// ── Letter IPA ────────────────────────────────────────────────────────
#[test]
fn letter_ipa_covers_alphabet() {
for c in 'a'..='z' {
let ipa = letter_ipa(c);
assert!(!ipa.is_empty(), "letter_ipa('{c}') returned empty");
}
}
#[test]
fn letter_ipa_non_alpha_empty() {
assert_eq!(letter_ipa('1'), "");
assert_eq!(letter_ipa('!'), "");
}
// ── Punctuation passthrough ───────────────────────────────────────────
#[test]
fn passthrough_punct_includes_expected() {
assert!(is_passthrough_punct('.'));
assert!(is_passthrough_punct('!'));
assert!(is_passthrough_punct('?'));
assert!(is_passthrough_punct(' '));
assert!(is_passthrough_punct(','));
}
#[test]
fn passthrough_punct_excludes_alpha() {
assert!(!is_passthrough_punct('a'));
assert!(!is_passthrough_punct('Z'));
}
// ── VoiceStyle ────────────────────────────────────────────────────────
#[test]
fn voice_style_get_clamps_to_last_row() {
// 2 rows of 256 floats
let data: Vec<f32> = (0..512).map(|i| i as f32).collect();
let style = VoiceStyle { data };
// Row 0
assert_eq!(style.get(0)[0], 0.0);
// Row 1
assert_eq!(style.get(1)[0], 256.0);
// Row 999 should clamp to row 1 (last available)
assert_eq!(style.get(999)[0], 256.0);
}
#[test]
fn load_voice_style_rejects_too_small() {
use std::io::Write;
let dir = std::env::temp_dir().join("kokoro_test_small");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("tiny.bin");
let mut f = std::fs::File::create(&path).unwrap();
// Write only 100 floats (need at least 256)
for i in 0..100u32 {
f.write_all(&(i as f32).to_le_bytes()).unwrap();
}
drop(f);
let result = load_voice_style(&path);
assert!(result.is_err(), "should reject file with < 256 floats");
assert!(result.unwrap_err().contains("too small"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_voice_style_rejects_non_multiple_of_256() {
use std::io::Write;
let dir = std::env::temp_dir().join("kokoro_test_nonaligned");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("nonaligned.bin");
let mut f = std::fs::File::create(&path).unwrap();
// Write 257 floats — not a multiple of 256 (remainder = 1)
for i in 0..257u32 {
f.write_all(&(i as f32).to_le_bytes()).unwrap();
}
drop(f);
let result = load_voice_style(&path);
assert!(
result.is_err(),
"should reject file with non-multiple-of-256 floats"
);
let err = result.unwrap_err();
assert!(
err.contains("257"),
"error should mention float count: {err}"
);
assert!(
err.contains("remainder"),
"error should mention remainder: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
// ── Suffix rules ──────────────────────────────────────────────────────
#[test]
fn apply_s_voiceless() {
// After voiceless consonants: +s
assert!(Lexicon::apply_s("kæt").ends_with('s'));
}
#[test]
fn apply_s_sibilant() {
// After sibilants: +ᵻz
assert!(Lexicon::apply_s("bʌz").ends_with("ᵻz"));
}
#[test]
fn apply_s_voiced() {
// After voiced consonants: +z
assert!(Lexicon::apply_s("dɔɡ").ends_with('z'));
}
#[test]
fn apply_ed_voiceless() {
// After voiceless: +t
assert!(Lexicon::apply_ed("wɔk").ends_with('t'));
}
#[test]
fn apply_ed_d_ending() {
// After d: +ᵻd
assert!(Lexicon::apply_ed("æd").ends_with("ᵻd"));
}
#[test]
fn apply_ing_basic() {
assert!(Lexicon::apply_ing("rʌn").ends_with("ɪŋ"));
}
#[test]
fn hyphenated_word_splits_into_parts() {
let lex = Lexicon {
misaki: HashMap::new(),
cmudict: HashMap::new(),
};
let result = lex.word_to_ipa("short-and-natural");
let space_count = result.matches(' ').count();
assert_eq!(
space_count, 2,
"expected 2 spaces for 3 hyphenated parts, got {space_count}: {result}"
);
}
#[test]
fn underscored_word_splits_into_parts() {
let lex = Lexicon {
misaki: HashMap::new(),
cmudict: HashMap::new(),
};
let result = lex.word_to_ipa("parent_event_id");
let space_count = result.matches(' ').count();
assert_eq!(
space_count, 2,
"expected 2 spaces for 3 underscored parts, got {space_count}: {result}"
);
}
#[test]
fn compound_word_with_dict_lookup() {
let mut dict = HashMap::new();
dict.insert("parent".to_string(), "pɛɹənt".to_string());
dict.insert("event".to_string(), "ɪvɛnt".to_string());
dict.insert("id".to_string(), "aɪdiː".to_string());
let lex = Lexicon {
misaki: dict,
cmudict: HashMap::new(),
};
// Underscore compound
let result = lex.word_to_ipa("parent_event_id");
assert!(result.contains("pɛɹənt"), "parent not resolved: {result}");
assert!(result.contains("ɪvɛnt"), "event not resolved: {result}");
assert!(result.contains("aɪdiː"), "id not resolved: {result}");
// Hyphen compound
let result = lex.word_to_ipa("short-and-sweet");
assert_eq!(result.matches(' ').count(), 2, "hyphen split: {result}");
}
}

View File

@@ -25,9 +25,9 @@
pub mod agents;
pub mod audio_output;
pub mod kokoro;
pub mod models;
pub mod pipeline;
pub mod pocket;
pub mod preprocessing;
pub mod relay_api;
pub mod state;
@@ -661,12 +661,12 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S
let stt_ready = models::global_model_manager()
.map(|m| m.take_stt_ready())
.unwrap_or(false);
let kokoro_ready = models::global_model_manager()
.map(|m| m.take_kokoro_ready())
let tts_ready = models::global_model_manager()
.map(|m| m.take_tts_ready())
.unwrap_or(false);
// Start TTS first (so STT can capture tts_cancel).
if !has_tts && (kokoro_ready || models::is_kokoro_ready()) {
if !has_tts && (tts_ready || models::is_tts_ready()) {
if let Err(e) = maybe_start_tts_pipeline(&state).await {
eprintln!("sprout-desktop: TTS hotstart failed: {e}");
}
@@ -751,7 +751,7 @@ pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String
}
}
/// Trigger a background download of voice models (Parakeet STT + Kokoro TTS).
/// Trigger a background download of voice models (Parakeet STT + Pocket TTS).
///
/// Returns immediately — downloads run in tokio background tasks.
/// Poll `get_model_status` to track progress.
@@ -761,7 +761,7 @@ pub async fn download_voice_models(state: State<'_, AppState>) -> Result<(), Str
let manager = models::global_model_manager()
.ok_or("model manager unavailable (home directory could not be resolved)")?;
manager.start_stt_download(state.http_client.clone());
manager.start_kokoro_download(state.http_client.clone());
manager.start_tts_download(state.http_client.clone());
Ok(())
}
@@ -772,14 +772,14 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result<models::VoiceMode
.ok_or("model manager unavailable (home directory could not be resolved)")?;
Ok(models::VoiceModelStatus {
stt: manager.stt_status(),
kokoro: manager.kokoro_status(),
tts: manager.tts_status(),
})
}
/// Enable or disable TTS output.
///
/// When disabled, the TTS pipeline is shut down and audio output stops.
/// When re-enabled, the pipeline is restarted if Kokoro models are available.
/// When re-enabled, the pipeline is restarted if TTS models are available.
///
/// Takes the pipeline handle out of the lock before calling shutdown() — the
/// thread join in Drop can block for ~200 ms (ONNX inference) and we don't

View File

@@ -1,10 +1,10 @@
//! Model download manager for STT (Parakeet TDT-CTC 110M) and TTS (Kokoro) models.
//! Model download manager for STT (Parakeet TDT-CTC 110M) and TTS (Pocket TTS) models.
//!
//! Mental model:
//! app launch → start_stt_download (background) → ~/.sprout/models/parakeet-tdt-ctc-110m-en/
//! app launch → start_kokoro_download (background) → ~/.sprout/models/kokoro/
//! app launch → start_tts_download (background) → ~/.sprout/models/pocket-tts/
//! STT pipeline → is_stt_ready() → stt_model_dir() → run inference
//! TTS pipeline → is_kokoro_ready() → kokoro_model_dir() → run synthesis
//! TTS pipeline → is_tts_ready() → tts_model_dir() → run synthesis
//!
//! Models are downloaded once and cached. A version manifest (`.sprout-model-manifest`)
//! is written alongside model files — if the on-disk version doesn't match the
@@ -38,18 +38,38 @@ use sha2::{Digest, Sha256};
/// Computed from a known-good download. Update when upgrading model versions.
const STT_ARCHIVE_SHA256: &str = "17f945007b52ccd8b7200ffc7c5652e9e8e961dfdf479cefcabd06cf5703630b";
/// SHA-256 hashes for individual Kokoro model files.
/// Computed from known-good downloads. Update when upgrading model versions.
/// HuggingFace base URL for the sherpa-onnx Pocket TTS int8 repackage.
///
/// model.onnx (model_q8f16.onnx, 86 MB):
/// curl -sL "https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/main/onnx/model_q8f16.onnx" | shasum -a 256
/// Pinned to commit e715955cf50d18d919d37231513c0e914b83661a
/// (2026-02-10) for reproducible downloads.
const POCKET_HF_BASE: &str =
"https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-int8-2026-01-26/resolve/e715955cf50d18d919d37231513c0e914b83661a";
/// Reference voice WAV: "Mary (f, conversation)" from the Kyutai TTS demo
/// voice set — VCTK speaker p333, ai-coustics-enhanced. Pinned to
/// kyutai/tts-voices commit 323332d33f997de8394f24a193e1a76df720e01a.
///
/// Mapping comes from the speaker dropdown on <https://kyutai.org/tts>:
/// the Pocket TTS preset "Mary (f, conversation)" maps to
/// `vctk/p333_023_enhanced.wav`. We rename to `reference_sample.wav` on disk
/// so the rest of the engine code stays voice-agnostic; the friendly label
/// only matters for attribution and PR-body docs.
const POCKET_REFERENCE_WAV_URL: &str =
"https://huggingface.co/kyutai/tts-voices/resolve/323332d33f997de8394f24a193e1a76df720e01a/vctk/p333_023_enhanced.wav";
/// SHA-256 hashes for individual Pocket TTS model files.
/// Computed from known-good pinned downloads. Update when upgrading model versions.
#[rustfmt::skip]
const KOKORO_FILE_HASHES: &[(&str, &str)] = &[
("model.onnx", "04c658aec1b6008857c2ad10f8c589d4180d0ec427e7e6118ceb487e215c3cd0"),
("af_heart.bin", "d583ccff3cdca2f7fae535cb998ac07e9fcb90f09737b9a41fa2734ec44a8f0b"),
("us_gold.json", "dc414872a49a28ae6c141463d502fd945f3b2fde040484fdc47d00cc4612686f"),
("us_silver.json", "de8f67be911bb6c659187b4a65fd966b6a30e56350e0f790d763210b053ac475"),
("cmudict.dict", "81917843c7f44ce2b094ac63873c2c7a4cf802040792c455ba3ca406891c3d22"),
const TTS_FILE_HASHES: &[(&str, &str)] = &[
("decoder.int8.onnx", "12b0857402d31aead94df19d6783b4350d1f740e811f3a3202c70ad89ae11eea"),
("encoder.onnx", "e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260"),
("lm_flow.int8.onnx", "8d627d235c44a597da908e1085ebe241cbbe358964c502c5a5063d18851a5529"),
("lm_main.int8.onnx", "bfc0c7e7e3d72864fa3bb2ee499f62f21ddc1474b885f5f3ca570f8be73e787e"),
("text_conditioner.onnx", "0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1"),
("vocab.json", "6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01"),
("token_scores.json", "5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958"),
("LICENSE", "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6"),
("reference_sample.wav", "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f"),
];
// ── Model versioning ──────────────────────────────────────────────────────────
@@ -65,8 +85,13 @@ const KOKORO_FILE_HASHES: &[(&str, &str)] = &[
/// honest (each version tag identifies one specific set of model bytes).
const STT_MODEL_VERSION: &str = "2";
/// Model manifest version for Kokoro. Increment when upgrading model files.
const KOKORO_MODEL_VERSION: &str = "1";
/// Model manifest version for Pocket TTS. Increment when upgrading model files.
/// Bumped "1" → "2" when the bundled reference voice changed from KevinAHM's
/// anonymous 16 kHz sample to Mary (VCTK p333, 32 kHz, ai-coustics-enhanced)
/// from kyutai/tts-voices. The hash mismatch on `reference_sample.wav` would
/// fail readiness on its own, but the manifest bump makes the re-download
/// reason explicit and skips the failing-then-re-fetching transient state.
const TTS_MODEL_VERSION: &str = "2";
/// Filename for the version manifest written alongside model files.
const MANIFEST_FILENAME: &str = ".sprout-model-manifest";
@@ -76,8 +101,8 @@ const MANIFEST_FILENAME: &str = ".sprout-model-manifest";
/// Maximum expected STT archive size (200 MB — actual is ~100 MB).
const MAX_STT_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024;
/// Maximum expected Kokoro file size (200 MB per file — model is 86 MB).
const MAX_KOKORO_FILE_BYTES: u64 = 200 * 1024 * 1024;
/// Maximum expected Pocket TTS file size (200 MB per file — largest is ~73 MB).
const MAX_TTS_FILE_BYTES: u64 = 200 * 1024 * 1024;
/// NVIDIA Parakeet TDT-CTC 110M (English, int8) — packaged for sherpa-onnx by
/// k2-fsa. Single ONNX file (CTC head) + tokens.txt. Avg WER ~7.5% across
@@ -125,35 +150,59 @@ Provided \"AS IS\", without warranty of any kind, express or implied. See the
license text for full warranty disclaimer.
";
// ── Kokoro TTS model ─────────────────────────────────────────────────────────
/// HuggingFace base URL for Kokoro ONNX model files.
const KOKORO_HF_BASE: &str =
"https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/main";
/// Misaki G2P lexicons — pinned to commit fba1236 for reproducibility.
/// Gold = curated pronunciations. Silver = broader coverage (93K words).
/// Both are needed: gold is checked first, silver catches common words gold misses.
const KOKORO_LEXICON_GOLD_URL: &str =
"https://raw.githubusercontent.com/hexgrad/misaki/fba1236/misaki/data/us_gold.json";
const KOKORO_LEXICON_SILVER_URL: &str =
"https://raw.githubusercontent.com/hexgrad/misaki/fba1236/misaki/data/us_silver.json";
/// CMU Pronouncing Dictionary — 135K entries including inflected forms.
/// BSD 2-Clause license (Carnegie Mellon University). Compatible with Apache-2.0.
const KOKORO_CMUDICT_URL: &str =
"https://raw.githubusercontent.com/cmusphinx/cmudict/master/cmudict.dict";
// ── Pocket TTS model ──────────────────────────────────────────────────────────
/// Final directory name under `~/.sprout/models/`.
const KOKORO_MODEL_DIR_NAME: &str = "kokoro";
const TTS_MODEL_DIR_NAME: &str = "pocket-tts";
/// All files that must be present for Kokoro to be considered ready.
const KOKORO_EXPECTED_FILES: &[&str] = &[
"model.onnx",
"af_heart.bin",
"us_gold.json",
"us_silver.json",
"cmudict.dict",
/// Attribution sidecar written next to the Pocket TTS model files.
const TTS_LICENSE_FILE_NAME: &str = "MODEL_LICENSE.txt";
/// CC-BY-4.0 §3(a)(1) attribution block for Pocket TTS, its ONNX packaging,
/// and the bundled reference voice WAV.
const TTS_LICENSE_TEXT: &str = "\
Pocket TTS
© Kyutai.
Licensed under the Creative Commons Attribution 4.0 International License
(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/
Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts
Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926).
Mimi neural codec by Kyutai is bundled as part of the model.
ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx
Sherpa-onnx repackage by csukuangfj / k2-fsa:
https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-int8-2026-01-26
Bundled reference voice (reference_sample.wav):
\"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue
(https://kyutai.org/tts), distributed via
https://huggingface.co/kyutai/tts-voices as `vctk/p333_023_enhanced.wav`.
Original recording from the Voice Cloning Toolkit (VCTK) corpus, speaker p333:
https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0).
Recording enhancement (denoise/dereverb) by ai-coustics:
https://ai-coustics.com/
Sprout ships all ONNX/model artifacts and the reference voice WAV unmodified,
renamed only by placement in the local model directory.
Provided \"AS IS\", without warranty of any kind, express or implied. See the
license text for full warranty disclaimer.
";
/// All files that must be present for Pocket TTS to be considered ready.
const TTS_EXPECTED_FILES: &[&str] = &[
"decoder.int8.onnx",
"encoder.onnx",
"lm_flow.int8.onnx",
"lm_main.int8.onnx",
"text_conditioner.onnx",
"vocab.json",
"token_scores.json",
"LICENSE",
"reference_sample.wav",
TTS_LICENSE_FILE_NAME,
];
// ── Status types ──────────────────────────────────────────────────────────────
@@ -176,7 +225,7 @@ pub enum ModelStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceModelStatus {
pub stt: ModelStatus,
pub kokoro: ModelStatus,
pub tts: ModelStatus,
}
// ── Safe archive extraction ───────────────────────────────────────────────────
@@ -339,7 +388,7 @@ where
// ── ModelSlot ─────────────────────────────────────────────────────────────────
/// Per-model state + config. `ModelManager` owns two of these (stt, kokoro).
/// Per-model state + config. `ModelManager` owns two of these (stt, tts).
#[derive(Clone)]
struct ModelSlot {
dir_name: &'static str, // subdir under ~/.sprout/models/
@@ -493,7 +542,7 @@ pub struct ModelManager {
/// `~/.sprout/models/`
models_dir: PathBuf,
stt: ModelSlot,
kokoro: ModelSlot,
tts: ModelSlot,
}
impl ModelManager {
@@ -505,11 +554,7 @@ impl ModelManager {
Some(Self {
models_dir,
stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION),
kokoro: ModelSlot::new(
KOKORO_MODEL_DIR_NAME,
KOKORO_EXPECTED_FILES,
KOKORO_MODEL_VERSION,
),
tts: ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION),
})
}
@@ -532,23 +577,23 @@ impl ModelManager {
self.stt.take_ready()
}
// ── Kokoro accessors ──────────────────────────────────────────────────────
// ── TTS accessors ─────────────────────────────────────────────────────────
/// Path to the Kokoro model directory, or `None` if not ready.
pub fn kokoro_model_dir(&self) -> Option<PathBuf> {
self.kokoro.dir_if_ready(&self.models_dir)
/// Path to the TTS model directory, or `None` if not ready.
pub fn tts_model_dir(&self) -> Option<PathBuf> {
self.tts.dir_if_ready(&self.models_dir)
}
/// `true` if all Kokoro files are present and the manifest version matches.
pub fn is_kokoro_ready(&self) -> bool {
self.kokoro.is_ready(&self.models_dir)
/// `true` if all TTS files are present and the manifest version matches.
pub fn is_tts_ready(&self) -> bool {
self.tts.is_ready(&self.models_dir)
}
/// Current Kokoro download status.
pub fn kokoro_status(&self) -> ModelStatus {
self.kokoro.status()
/// Current TTS download status.
pub fn tts_status(&self) -> ModelStatus {
self.tts.status()
}
/// Returns `true` once when Kokoro just became ready. Resets the flag.
pub fn take_kokoro_ready(&self) -> bool {
self.kokoro.take_ready()
/// Returns `true` once when TTS just became ready. Resets the flag.
pub fn take_tts_ready(&self) -> bool {
self.tts.take_ready()
}
// ── Download triggers ─────────────────────────────────────────────────────
@@ -583,14 +628,14 @@ impl ModelManager {
}
}
/// Start a background Kokoro download (~87 MB). No-op if already ready or downloading.
pub fn start_kokoro_download(&self, http_client: reqwest::Client) {
/// Start a background Pocket TTS download (~189 MB). No-op if already ready or downloading.
pub fn start_tts_download(&self, http_client: reqwest::Client) {
let manager = self.clone();
self.kokoro.start_download(
self.tts.start_download(
&self.models_dir,
http_client,
"kokoro",
move |client| async move { manager.download_kokoro_model(client).await },
"tts",
move |client| async move { manager.download_tts_model(client).await },
);
}
@@ -697,40 +742,42 @@ impl ModelManager {
Ok(())
}
/// Download and verify the Kokoro TTS model files from HuggingFace and GitHub.
/// Download and verify the Pocket TTS model files from HuggingFace.
///
/// Downloads files into `~/.sprout/models/kokoro/`:
/// - `model.onnx` — Kokoro-82M mixed-precision ONNX (86 MB)
/// - `af_heart.bin` — best-quality American English voice embedding (510 KB)
/// - `us_gold.json` — Misaki G2P lexicon, pinned to commit fba1236 (3 MB)
/// Downloads files into `~/.sprout/models/pocket-tts/`:
/// - five ONNX sessions (Pocket TTS + Mimi codec)
/// - `vocab.json` / `token_scores.json` for sherpa-onnx text conditioning
/// - upstream `LICENSE` plus Sprout's `MODEL_LICENSE.txt` attribution sidecar
/// - `reference_sample.wav` as the bundled default voice
///
/// Files are written to a temp directory first, then moved atomically.
async fn download_kokoro_model(&self, http_client: reqwest::Client) -> Result<(), String> {
async fn download_tts_model(&self, http_client: reqwest::Client) -> Result<(), String> {
tokio::fs::create_dir_all(&self.models_dir)
.await
.map_err(|e| format!("create models dir: {e}"))?;
let temp_dir = self.models_dir.join("kokoro.tmp");
let temp_dir = self.models_dir.join("pocket-tts.tmp");
fresh_temp_dir(&temp_dir).await?;
// (url, local_filename)
let downloads: &[(&str, &str)] = &[
(
&format!("{KOKORO_HF_BASE}/onnx/model_q8f16.onnx"),
"model.onnx",
),
(
&format!("{KOKORO_HF_BASE}/voices/af_heart.bin"),
"af_heart.bin",
),
(KOKORO_LEXICON_GOLD_URL, "us_gold.json"),
(KOKORO_LEXICON_SILVER_URL, "us_silver.json"),
(KOKORO_CMUDICT_URL, "cmudict.dict"),
let model_files = [
"decoder.int8.onnx",
"encoder.onnx",
"lm_flow.int8.onnx",
"lm_main.int8.onnx",
"text_conditioner.onnx",
"vocab.json",
"token_scores.json",
"LICENSE",
];
let mut downloads: Vec<(String, &'static str)> = model_files
.iter()
.map(|filename| (format!("{POCKET_HF_BASE}/{filename}"), *filename))
.collect();
downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), "reference_sample.wav"));
let total_files = downloads.len() as u32;
for (i, (url, filename)) in downloads.iter().enumerate() {
eprintln!("sprout-desktop: downloading Kokoro {filename} from {url}");
eprintln!("sprout-desktop: downloading Pocket TTS {filename} from {url}");
let response = fetch_url(&http_client, url, filename).await.map_err(|e| {
let _ = std::fs::remove_dir_all(&temp_dir);
@@ -738,12 +785,12 @@ impl ModelManager {
})?;
let dest = temp_dir.join(filename);
let slot = self.kokoro.clone();
let slot = self.tts.clone();
let file_index = i as u32;
let bytes = download_file(
response,
&dest,
MAX_KOKORO_FILE_BYTES,
MAX_TTS_FILE_BYTES,
filename,
|downloaded, content_length| {
if let Some(total) = content_length {
@@ -766,30 +813,36 @@ impl ModelManager {
})?;
eprintln!("sprout-desktop: downloaded {bytes} bytes ({filename}), wrote to disk");
// Verify file integrity against pinned hash.
if let Some(&(_, expected)) = KOKORO_FILE_HASHES.iter().find(|(n, _)| *n == *filename) {
let actual = sha256_file(&dest).await?;
if actual != expected {
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
return Err(format!(
"Kokoro {filename} integrity check failed: expected {expected}, got {actual}"
));
}
let expected = TTS_FILE_HASHES
.iter()
.find(|(n, _)| *n == *filename)
.map(|(_, hash)| *hash)
.ok_or_else(|| format!("missing expected hash for Pocket TTS file: {filename}"))?;
let actual = sha256_file(&dest).await?;
if actual != expected {
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
return Err(format!(
"Pocket TTS {filename} integrity check failed: expected {expected}, got {actual}"
));
}
// Ensure progress reflects file completion even without content-length.
let pct = (((i as u32 + 1) * 89) / total_files).min(89) as u8;
self.kokoro.set_status(ModelStatus::Downloading {
self.tts.set_status(ModelStatus::Downloading {
progress_percent: pct,
});
}
self.kokoro.set_status(ModelStatus::Downloading {
tokio::fs::write(temp_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT)
.await
.map_err(|e| format!("write TTS model license sidecar: {e}"))?;
self.tts.set_status(ModelStatus::Downloading {
progress_percent: 90,
});
if let Err(e) = self
.kokoro
.tts
.verify_and_install(&self.models_dir, &temp_dir, None)
.await
{
@@ -798,8 +851,8 @@ impl ModelManager {
}
eprintln!(
"sprout-desktop: Kokoro model ready at {}",
self.kokoro.model_dir(&self.models_dir).display()
"sprout-desktop: Pocket TTS model ready at {}",
self.tts.model_dir(&self.models_dir).display()
);
Ok(())
}
@@ -856,14 +909,37 @@ async fn cleanup_legacy_moonshine_dir(models_dir: &Path) {
}
}
/// Path to the Kokoro model directory, or `None` if not ready.
pub fn kokoro_model_dir() -> Option<PathBuf> {
global_model_manager()?.kokoro_model_dir()
/// Path to the TTS model directory, or `None` if not ready.
pub fn tts_model_dir() -> Option<PathBuf> {
global_model_manager()?.tts_model_dir()
}
/// `true` if all expected Kokoro model files are present on disk.
pub fn is_kokoro_ready() -> bool {
/// `true` if all expected TTS model files are present on disk.
pub fn is_tts_ready() -> bool {
global_model_manager()
.map(|m| m.is_kokoro_ready())
.map(|m| m.is_tts_ready())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tts_readiness_requires_license_sidecar() {
let temp = tempfile::tempdir().expect("tempdir");
let slot = ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION);
let model_dir = temp.path().join(TTS_MODEL_DIR_NAME);
std::fs::create_dir_all(&model_dir).expect("create model dir");
for file in TTS_EXPECTED_FILES {
std::fs::write(model_dir.join(file), b"test").expect("write expected file");
}
std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest");
assert!(slot.is_ready(temp.path()));
std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar");
assert!(!slot.is_ready(temp.path()));
}
}

View File

@@ -41,7 +41,7 @@ pub(crate) async fn post_connect_setup(
// Ensure voice models are downloading (idempotent).
if let Some(mgr) = models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
mgr.start_kokoro_download(state.http_client.clone());
mgr.start_tts_download(state.http_client.clone());
}
// Connect audio relay WebSocket (Opus encode/decode pipeline).
@@ -153,7 +153,7 @@ pub(crate) async fn maybe_start_stt_pipeline(
Ok(true)
}
/// Attempt to start the TTS pipeline if Kokoro models are present and TTS is enabled.
/// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled.
///
/// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions
/// aren't met (model not ready, pipeline exists, TTS disabled), or `Err` on failure.
@@ -164,11 +164,11 @@ pub(crate) async fn maybe_start_stt_pipeline(
/// leaks ~200MB of ONNX sessions. The sentinel is set under the lock before
/// releasing it for the expensive construction step.
pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, String> {
if !models::is_kokoro_ready() {
return Ok(false); // Kokoro not downloaded yet — TTS unavailable.
if !models::is_tts_ready() {
return Ok(false); // TTS model not downloaded yet — TTS unavailable.
}
let model_dir = match models::kokoro_model_dir() {
let model_dir = match models::tts_model_dir() {
Some(d) => d,
None => return Ok(false),
};

View File

@@ -0,0 +1,872 @@
//! Pocket TTS engine wrapper around sherpa-onnx's `OfflineTts`.
//!
//! Pocket TTS is a small (~189 MB int8 ONNX) zero-shot voice-cloning TTS
//! model from Kyutai. It runs quickly on CPU via sherpa-onnx, replacing the
//! previous Kokoro-82M engine that also required an espeak-free but
//! lexicon-heavy G2P pipeline (Misaki + CMUdict).
//!
//! ## Attribution
//!
//! - **Model**: Kyutai *Pocket TTS* — Charles, Roebel, et al., 2026.
//! arXiv:2509.06926. Original repository: <https://huggingface.co/kyutai/pocket-tts>.
//! Licensed CC-BY-4.0.
//! - **Mimi neural codec**: Kyutai, bundled in the same release. CC-BY-4.0.
//! - **ONNX export**: KevinAHM —
//! <https://huggingface.co/KevinAHM/pocket-tts-onnx>. CC-BY-4.0.
//! - **sherpa-onnx repackage**: csukuangfj / k2-fsa —
//! <https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-int8-2026-01-26>.
//! Repackages KevinAHM's export with the file layout sherpa-onnx's
//! `OfflineTtsPocketModelConfig` expects. CC-BY-4.0.
//! - **Reference voice WAV** (`reference_sample.wav`): the "Mary
//! (f, conversation)" preset from the Kyutai TTS demo
//! (<https://kyutai.org/tts>), which maps to `vctk/p333_023_enhanced.wav`
//! in <https://huggingface.co/kyutai/tts-voices>. CC-BY-4.0, base recording
//! from the VCTK corpus, enhanced by ai-coustics.
//!
//! Sprout ships these files unmodified; see the on-disk `MODEL_LICENSE.txt`
//! sidecar written by `huddle::models` during install for the canonical
//! CC-BY-4.0 §3(a)(1) attribution block.
//!
//! ## Engine-module contract (see `huddle::tts`)
//!
//! `pocket.rs` exposes a fixed surface used by `tts.rs`. Mirroring this
//! contract is what lets the TTS pipeline stay engine-agnostic:
//!
//! - `SAMPLE_RATE: u32` — engine output sample rate in Hz.
//! - `DEFAULT_VOICE: &str` — default voice name (without extension).
//! - `VOICE_FILE_EXT: &str` — extension for per-voice files on disk.
//! - `load_text_to_speech(model_dir)` → `Result<Engine, String>`
//! - `load_voice_style(path)` → `Result<VoiceStyle, String>`
//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps, speed)`
//! → `Result<Vec<f32>, String>`
//!
//! `lang` and `steps` are accepted for API compatibility with the previous
//! Kokoro engine but are unused — Pocket TTS does its own language ID from
//! the input text and is not a diffusion model (consistency LM, one step).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave};
// ── Engine-module contract: public consts ─────────────────────────────────────
/// Pocket TTS emits 24 kHz mono PCM. Matches the previous Kokoro output rate,
/// so the rodio sink and inter-sentence silence buffer in `tts.rs` remain valid.
pub const SAMPLE_RATE: u32 = 24_000;
/// Name (without extension) of the bundled reference voice. The model directory
/// is expected to contain `<DEFAULT_VOICE>.<VOICE_FILE_EXT>` after install.
pub const DEFAULT_VOICE: &str = "reference_sample";
/// Voice files for Pocket TTS are reference audio (WAV). Distinct from the
/// Kokoro `.bin` style vectors — the model conditions on raw waveform samples,
/// not a precomputed embedding, so the extension change is honest.
pub const VOICE_FILE_EXT: &str = "wav";
// ── Tuning ────────────────────────────────────────────────────────────────────
/// Single-threaded ONNX execution for predictable CPU contention with the STT
/// pipeline. Matches `STT_NUM_THREADS` in `stt.rs`; raise only if a benchmark
/// argues for it.
const TTS_NUM_THREADS: i32 = 1;
/// LRU cache size for cloned voice embeddings inside the sherpa-onnx engine.
/// We bind to one voice per pipeline today, but the upstream example uses 16
/// and the cost is negligible — keep room for future multi-voice support.
const VOICE_EMBEDDING_CACHE_CAPACITY: i32 = 16;
/// Pocket TTS is a consistency-based LM. Generation quality saturates at one
/// denoising step — the upstream `GenerationConfig` default of 5 multiplies
/// synthesis time by ~5× with no audible benefit on this model.
const SYNTH_NUM_STEPS: i32 = 1;
/// Disable the upstream default 200 ms of pre/post silence padding. We splice
/// `INTER_SENTENCE_SILENCE` in `tts.rs` ourselves and don't want a double
/// helping of leading silence on every utterance.
const SYNTH_SILENCE_SCALE: f32 = 0.0;
/// sherpa-onnx upstream default for `max_frames` (LM steps), in
/// `offline-tts-pocket-impl.h:Generate`. 500 steps ≈ 40 s of audio at the
/// Mimi 12.5 Hz frame rate. Referenced only by the regression test below;
/// production code path never raises (or even reads) this value — we just
/// leave sherpa-onnx's own default in place by not setting the override.
#[cfg(test)]
const SHERPA_ONNX_MAX_FRAMES_DEFAULT: i32 = 500;
/// Tight `max_frames` we ask for on short, padded prompts to bound the
/// original "monster breathing" runaway. 100 LM steps ≈ 8 s of audio —
/// roomy for any one-to-four-word utterance the user is likely to elicit
/// while still well short of the 40 s upstream default. Chosen with slack so
/// we never *truncate* a legitimate short reply.
const SHORT_PROMPT_MAX_FRAMES: i32 = 100;
/// Word-count threshold (inclusive) below which we pad the prompt with
/// leading spaces and cap `max_frames` tighter than the upstream default.
/// Matches upstream `pocket_tts.models.tts_model.prepare_text_prompt`. Above
/// this threshold we leave sherpa-onnx's own defaults in place — overriding
/// them caused the "first 'yep' is just static" regression seen on
/// 2026-05-18, where dropping `frames_after_eos` below the upstream default
/// of 3 clipped the leading audio of multi-clause sentences.
const SHORT_PROMPT_WORD_THRESHOLD: usize = 4;
/// Number of leading spaces prepended to short prompts. The upstream Python
/// uses exactly 8 — keep parity rather than tuning blindly.
const SHORT_PROMPT_PAD_SPACES: usize = 8;
/// Sacrificial cold-start prefix appended *after* the leading space pad for
/// short prompts. Pocket TTS' FlowLM autoregressive generation has a 2–3
/// step "settle" period at the start where the first generated phoneme can
/// be smeared or dropped entirely (see kyutai-labs/pocket-tts #91, #70 and
/// sherpa-onnx #3180). For short utterances like "I'm happy." the first
/// phoneme is most of the first word — losing it produces "m happy".
///
/// Two periods separated by a space act as a "phantom utterance" that the
/// model commits to, absorbing the cold-start. The pair (rather than a
/// single period) was empirically the only variant in our probe — see
/// `examples/prod_probe.rs` — that produced a usable post-sacrificial
/// silence gap on every random seed. The resulting leading sacrificial
/// audio is then stripped from the output by [`trim_leading_cold_start`]
/// before the buffer is returned to the synth pipeline.
///
/// Long prompts (>4 words) don't need this — the first phoneme already has
/// enough downstream context to avoid the smear, and an early natural pause
/// (e.g. the comma in "Hello, how can I help you?") could be misdetected as
/// the trim boundary.
const SACRIFICIAL_PREFIX: &str = ". . ";
// ── Leading cold-start trim (post-synth) ──────────────────────────────────────
/// Skip this many samples at the start of the synth buffer before looking
/// for the sacrificial→main silence gap. The Mimi decoder cold-start
/// produces ~30 ms of low-amplitude noise that we *don't* want to treat as
/// the gap. 30 ms × 24 kHz = 720 samples.
const TRIM_SCAN_START_SAMPLES: usize = (SAMPLE_RATE as usize * 30) / 1000;
/// Amplitude threshold below which a sample is considered "silence" for the
/// purposes of finding the post-sacrificial gap. Tuned empirically against
/// production-config probe data — the engine's own `ScaleSilence` uses 0.01,
/// but our boundary detection wants a looser threshold so that the breath /
/// aspiration of the rendered periods (which sits around 0.005–0.015) is
/// treated as silence too.
const TRIM_SILENCE_THRESHOLD: f32 = 0.02;
/// A silence run must be at least this many samples long to be accepted as
/// the sacrificial→main word boundary. 50 ms is comfortably longer than
/// inter-syllable silence within a normal word at this speed (typically
/// 10–30 ms), so this guards against trimming into the middle of the real
/// utterance. 50 ms × 24 kHz = 1200 samples.
const TRIM_MIN_GAP_SAMPLES: usize = (SAMPLE_RATE as usize * 50) / 1000;
/// Hard cap on how much audio we'll trim from the start. Production probe
/// data (with `silence_scale = 0.0`) shows valid trim boundaries land
/// between 30 ms and ~450 ms; 1.2 s is a wide safety margin. If the
/// detector finds a "gap" past this point it's almost certainly an interior
/// pause inside an unusually long short-prompt utterance — bail out and
/// emit untrimmed audio rather than corrupt it. 1.2 s × 24 kHz = 28800
/// samples.
const TRIM_MAX_DROP_SAMPLES: usize = (SAMPLE_RATE as usize * 1200) / 1000;
/// sherpa-onnx's documented `frames_after_eos` default. We deliberately do
/// *not* override this knob — the previous attempt to bump it for short
/// inputs and lower it for long inputs lowered it below the upstream default
/// of 3, which clipped the leading audio of multi-clause sentences (the
/// "first 'yep' is static" regression). The constant exists only for the
/// regression test below. Source: `offline-tts-pocket-impl.h:Generate`.
#[cfg(test)]
const SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT: i32 = 3;
// ── ONNX file names (five Pocket TTS sessions plus two JSON tables) ───────────
const FILE_LM_MAIN: &str = "lm_main.int8.onnx";
const FILE_LM_FLOW: &str = "lm_flow.int8.onnx";
const FILE_ENCODER: &str = "encoder.onnx";
const FILE_DECODER: &str = "decoder.int8.onnx";
const FILE_TEXT_COND: &str = "text_conditioner.onnx";
const FILE_VOCAB: &str = "vocab.json";
const FILE_TOKEN_SCORES: &str = "token_scores.json";
// ── Voice style ───────────────────────────────────────────────────────────────
/// Loaded reference voice — normalised f32 PCM samples plus their sample rate.
///
/// Pocket TTS takes a reference waveform per generation call (not a
/// precomputed style embedding), so we keep the samples in memory and clone
/// the small `Vec` into each `GenerationConfig` rather than re-reading the
/// WAV from disk on every sentence.
#[derive(Debug, Clone)]
pub struct VoiceStyle {
samples: Vec<f32>,
sample_rate: i32,
}
/// Load a reference voice WAV from disk.
///
/// Accepts any sample rate sherpa-onnx's `Wave::read` can decode — Pocket TTS
/// resamples internally using `reference_sample_rate`. The bundled
/// `reference_sample.wav` ("Mary" — VCTK p333, enhanced) is 32 kHz mono.
pub fn load_voice_style(path: &Path) -> Result<VoiceStyle, String> {
let path_str = path
.to_str()
.ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?;
let wave = Wave::read(path_str)
.ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?;
let samples = wave.samples().to_vec();
if samples.is_empty() {
return Err(format!("voice WAV is empty: {}", path.display()));
}
Ok(VoiceStyle {
samples,
sample_rate: wave.sample_rate(),
})
}
// ── Engine ────────────────────────────────────────────────────────────────────
/// Pocket TTS engine handle. Cheap to construct (one `OfflineTts::create`
/// call). Owned by the TTS worker thread for the lifetime of a huddle session.
///
/// `OfflineTts` does not implement `Debug`, so we don't derive it here — the
/// pipeline only needs to move the engine into the worker thread and call
/// `synth_chunk` on it, never to print it.
pub struct PocketTts {
inner: OfflineTts,
}
/// Build the Pocket TTS engine from the model directory installed by
/// `huddle::models`. Returns `Err` if any expected ONNX or JSON file is
/// missing — readiness is normally enforced by `is_tts_ready` upstream, but
/// the check is repeated here so a manually-modified model dir produces a
/// clear error string instead of an opaque sherpa-onnx `None`.
pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
let dir = PathBuf::from(model_dir);
for name in [
FILE_LM_MAIN,
FILE_LM_FLOW,
FILE_ENCODER,
FILE_DECODER,
FILE_TEXT_COND,
FILE_VOCAB,
FILE_TOKEN_SCORES,
] {
let p = dir.join(name);
if !p.is_file() {
return Err(format!("missing Pocket TTS file: {}", p.display()));
}
}
let to_str = |name: &str| -> String { dir.join(name).to_string_lossy().into_owned() };
// Build the config by mutating defaults — mirrors `stt.rs` and stays
// resilient if sherpa-onnx adds unrelated model-family fields.
let mut cfg = OfflineTtsConfig::default();
cfg.model.pocket.lm_main = Some(to_str(FILE_LM_MAIN));
cfg.model.pocket.lm_flow = Some(to_str(FILE_LM_FLOW));
cfg.model.pocket.encoder = Some(to_str(FILE_ENCODER));
cfg.model.pocket.decoder = Some(to_str(FILE_DECODER));
cfg.model.pocket.text_conditioner = Some(to_str(FILE_TEXT_COND));
cfg.model.pocket.vocab_json = Some(to_str(FILE_VOCAB));
cfg.model.pocket.token_scores_json = Some(to_str(FILE_TOKEN_SCORES));
cfg.model.pocket.voice_embedding_cache_capacity = VOICE_EMBEDDING_CACHE_CAPACITY;
cfg.model.num_threads = TTS_NUM_THREADS;
// Explicit — defaults are not part of the API contract, and noisy debug
// logging in release builds would be expensive on every synthesized chunk.
cfg.model.debug = false;
let inner = OfflineTts::create(&cfg)
.ok_or_else(|| "OfflineTts::create returned None for Pocket TTS".to_string())?;
Ok(PocketTts { inner })
}
// ── Prompt preparation ────────────────────────────────────────────────────────
/// Result of [`prepare_pocket_prompt`]: a synthesizer-ready prompt plus the
/// per-call generation overrides derived from the original text.
///
/// `None` for either override means "leave sherpa-onnx's documented default
/// in place". The pipeline only sets `max_frames` (and only for short
/// padded inputs) so it can bound the original "monster breathing" runaway
/// without disturbing the rest of the LM sampling envelope.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PreparedPrompt {
/// Text to hand to `OfflineTts::generate_with_config`. Capitalized,
/// punctuation-terminated, and (for short inputs) left-padded with spaces
/// plus a sacrificial `". . "` cold-start prefix.
pub text: String,
/// Value to pass via `GenerationConfig.extra["max_frames"]`, or `None` to
/// keep the upstream default of 500 LM steps. We only override on short
/// padded prompts where we have a tight expectation on output length.
pub max_frames: Option<i32>,
/// `true` iff this prompt received the short-input treatment (leading
/// space pad + sacrificial `". . "` prefix). The synth pipeline uses
/// this to decide whether to apply [`trim_leading_cold_start`] to the
/// output: long prompts have no sacrificial audio to strip, and
/// trimming them risks deleting real speech at a natural early pause
/// (e.g. the comma in "Hello, how can I help you?").
pub is_short: bool,
}
/// Mirror of the *text-preparation* half of upstream
/// `pocket_tts.models.tts_model.prepare_text_prompt`. Sherpa-onnx's C++
/// Pocket TTS impl does not run these preparation steps, so short /
/// unpunctuated / lowercase inputs can trigger up to 40 s of runaway
/// generation when the EOS logit never crosses its threshold. We replicate
/// the upstream Python recipe here:
///
/// 1. Collapse interior whitespace (already done by `preprocess_for_tts`, but
/// cheap to re-check after sentence splitting).
/// 2. Capitalize the first letter.
/// 3. Append `.` if the text doesn't end in punctuation.
/// 4. If fewer than five words, prepend `SHORT_PROMPT_PAD_SPACES` spaces
/// followed by [`SACRIFICIAL_PREFIX`] (a `". . "` cold-start absorber —
/// see its docstring for the bug it works around), and return a tight
/// [`SHORT_PROMPT_MAX_FRAMES`] cap so the LM can't run away if EOS
/// still doesn't fire.
///
/// We do **not** override `frames_after_eos` — sherpa-onnx's default of 3
/// is what we want. An earlier version set it to 1 on long inputs, which
/// clipped the leading audio of multi-clause sentences ("first 'yep' is
/// just static" regression). Tests `prepare_prompt_never_lowers_frames_…`
/// lock this in.
///
/// Returns `None` only if the input is empty after trimming — caller should
/// skip synthesis in that case.
pub(crate) fn prepare_pocket_prompt(input: &str) -> Option<PreparedPrompt> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
// Collapse stray double-spaces / embedded newlines that may slip past
// `preprocess_for_tts` when sentences are spliced back together.
let mut cleaned = String::with_capacity(trimmed.len());
let mut last_was_space = false;
for ch in trimmed.chars() {
let is_ws = ch.is_whitespace();
if is_ws {
if !last_was_space {
cleaned.push(' ');
}
last_was_space = true;
} else {
cleaned.push(ch);
last_was_space = false;
}
}
// Capitalize first character. Uses `to_uppercase` (multi-codepoint safe).
let first = cleaned.chars().next().expect("cleaned non-empty above");
if first.is_lowercase() {
let upper: String = first.to_uppercase().collect();
let mut iter = cleaned.chars();
iter.next();
cleaned = upper + iter.as_str();
}
// Ensure terminal punctuation. Anything not in `.!?;:,` gets a period.
// The upstream Python only checks `isalnum` → period, but for our agent
// text we already may end in `!` `?` `.` etc. — treat any of those as OK.
let last = cleaned
.chars()
.next_back()
.expect("cleaned non-empty above");
if !matches!(last, '.' | '!' | '?' | ';' | ':' | ',') {
cleaned.push('.');
}
// Word count of the *cleaned but not padded* text — padding is whitespace
// only and would just lie to the threshold check below.
let word_count = cleaned.split_whitespace().count();
let is_short = word_count <= SHORT_PROMPT_WORD_THRESHOLD;
let (final_text, max_frames) = if is_short {
let mut padded = String::with_capacity(
cleaned.len() + SHORT_PROMPT_PAD_SPACES + SACRIFICIAL_PREFIX.len(),
);
for _ in 0..SHORT_PROMPT_PAD_SPACES {
padded.push(' ');
}
padded.push_str(SACRIFICIAL_PREFIX);
padded.push_str(&cleaned);
(padded, Some(SHORT_PROMPT_MAX_FRAMES))
} else {
// For everything ≥5 words, fall back to upstream defaults. Overriding
// these is what caused the "first 'yep' is static" regression — the
// upstream LM has been tuned for `frames_after_eos = 3` and
// `max_frames = 500`, and there's no clear win in second-guessing.
(cleaned, None)
};
Some(PreparedPrompt {
text: final_text,
max_frames,
is_short,
})
}
/// Strip the leading "sacrificial" audio produced by the `". . "` cold-start
/// prefix from a short-prompt synthesis result. Only call this when
/// [`PreparedPrompt::is_short`] is `true` — the trim looks for a long
/// silence run at the head of the buffer, and an early natural pause inside
/// a long unsacrificed utterance (e.g. the comma in "Hello, how can I help
/// you?") would be misclassified as the sacrificial gap.
///
/// Algorithm:
/// 1. Skip the first [`TRIM_SCAN_START_SAMPLES`] of the buffer (Mimi
/// cold-start noise we shouldn't classify as silence).
/// 2. Scan forward for the first run of samples below
/// [`TRIM_SILENCE_THRESHOLD`] that lasts at least
/// [`TRIM_MIN_GAP_SAMPLES`] — that's the post-sacrificial boundary.
/// 3. If that boundary lies beyond [`TRIM_MAX_DROP_SAMPLES`], treat it as
/// "almost certainly an interior pause" and *do not trim* — the safe
/// fallback is to play the slightly-degraded raw audio rather than
/// delete real speech.
/// 4. Otherwise, drop the leading samples up to the end of the silence
/// run. We don't insert a zero lead-in here — `tts.rs` owns playback
/// cushioning by prepending `SENTENCE_LEAD_IN_SAMPLES` of zeros before
/// each appended sentence chunk.
///
/// If the scan never finds a long-enough gap (≈1% of generations in the
/// production-config probe), the function is a no-op — the model trajectory
/// missed the expected sacrificial→main structure and we'd rather play the
/// raw buffer than emit silence.
fn trim_leading_cold_start(samples: &mut Vec<f32>) {
if samples.len() <= TRIM_SCAN_START_SAMPLES {
return;
}
let mut silence_run_start: Option<usize> = None;
let mut gap_end: Option<usize> = None;
for (i, sample) in samples.iter().enumerate().skip(TRIM_SCAN_START_SAMPLES) {
if sample.abs() < TRIM_SILENCE_THRESHOLD {
silence_run_start.get_or_insert(i);
} else if let Some(start) = silence_run_start {
if i - start >= TRIM_MIN_GAP_SAMPLES {
gap_end = Some(i);
break;
}
silence_run_start = None;
}
}
let Some(end) = gap_end else {
// No gap found — model didn't produce the expected sacrificial→main
// structure. Bail out and let the caller play the raw buffer.
return;
};
if end > TRIM_MAX_DROP_SAMPLES {
// Boundary too far into the audio to plausibly be the sacrificial
// gap. Almost certainly an interior pause in the real utterance —
// leave the audio alone.
return;
}
samples.drain(..end);
}
/// Build the `GenerationConfig.extra` HashMap from a [`PreparedPrompt`].
///
/// Centralised so the regression test below can assert that we **never**
/// emit a `frames_after_eos` override — the previous attempt to override
/// that knob (setting it to 1 for ≥5-word inputs) clipped the leading
/// audio of multi-clause sentences (the "first 'yep' is static" bug on
/// 2026-05-18). The upstream sherpa-onnx default of 3 is what we want, and
/// the right way to keep it is to not set it at all.
fn build_generation_extra(prepared: &PreparedPrompt) -> Option<HashMap<String, serde_json::Value>> {
prepared.max_frames.map(|mf| {
let mut h: HashMap<String, serde_json::Value> = HashMap::with_capacity(1);
h.insert("max_frames".to_string(), serde_json::Value::from(mf));
h
})
}
impl PocketTts {
/// Synthesise `text` with the given reference voice.
///
/// `_lang` and `_steps` are accepted for API compatibility with the
/// previous Kokoro engine. Pocket TTS infers language from the input text
/// directly and is a one-step consistency model. Returns an empty buffer
/// for whitespace-only input.
pub fn synth_chunk(
&self,
text: &str,
_lang: &str,
style: &VoiceStyle,
_steps: usize,
speed: f32,
) -> Result<Vec<f32>, String> {
// Mirror upstream pocket-tts prompt prep — without this short or
// unpunctuated inputs can cause the LM's EOS logit to never trip,
// producing up to 40 s of "monster breathing" garbage on the first
// utterance. See `prepare_pocket_prompt` for the full recipe.
let prepared = match prepare_pocket_prompt(text) {
Some(p) => p,
None => return Ok(Vec::new()),
};
// Per-call generation hints sherpa-onnx forwards to
// `offline-tts-pocket-impl.h`. We only override `max_frames`, and
// only for short padded prompts where we have a tight expectation
// on output length — that bounds the original runaway without
// disturbing the rest of the LM sampling envelope. See
// `prepare_pocket_prompt` docs for the regression history.
let extra = build_generation_extra(&prepared);
let cfg = GenerationConfig {
speed,
num_steps: SYNTH_NUM_STEPS,
silence_scale: SYNTH_SILENCE_SCALE,
reference_audio: Some(style.samples.clone()),
reference_sample_rate: style.sample_rate,
extra,
..Default::default()
};
// No progress callback — synthesis is fast enough that returning the
// whole buffer at once keeps the lookahead pipelining in `tts.rs`
// simple. `None::<fn(...) -> bool>` pins the callback type for the
// `generate_with_config` generic parameter.
let audio = self
.inner
.generate_with_config(&prepared.text, &cfg, None::<fn(&[f32], f32) -> bool>)
.ok_or_else(|| {
format!(
"Pocket TTS synthesis failed for text ({} chars)",
prepared.text.len()
)
})?;
let sample_rate = audio.sample_rate();
if sample_rate != SAMPLE_RATE as i32 {
eprintln!(
"sprout-desktop: Pocket TTS returned unexpected sample rate {sample_rate}Hz \
(expected {SAMPLE_RATE}Hz); playback speed may be wrong"
);
}
let mut samples = audio.samples().to_vec();
// For short prompts the prepared text includes a sacrificial ". . "
// prefix to absorb FlowLM/Mimi cold-start (see `SACRIFICIAL_PREFIX`).
// Strip the leading sacrificial audio before returning. Long prompts
// are never trimmed — they have no sacrificial audio, and an early
// natural pause could be mis-detected as the trim boundary.
if prepared.is_short {
trim_leading_cold_start(&mut samples);
}
Ok(samples)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── prepare_pocket_prompt ────────────────────────────────────────────────
#[test]
fn prepare_prompt_returns_none_for_empty_input() {
assert!(prepare_pocket_prompt("").is_none());
assert!(prepare_pocket_prompt(" ").is_none());
assert!(prepare_pocket_prompt("\n\t ").is_none());
}
/// Helper: the exact leading sequence prepended to every short prompt —
/// 8 spaces of padding followed by the sacrificial `". . "` cold-start
/// absorber. Centralising this keeps the assertions readable.
fn short_prefix() -> String {
let mut s = " ".repeat(SHORT_PROMPT_PAD_SPACES);
s.push_str(SACRIFICIAL_PREFIX);
s
}
#[test]
fn prepare_prompt_pads_and_capitalizes_one_word() {
// The "yep" case Tyler hit in production — bare lowercase one-word
// utterance with no punctuation. Must be padded with the short-prompt
// prefix (8 spaces + ". . " sacrificial), capitalized, terminated,
// with a tight `max_frames` cap to bound runaway gen.
let out = prepare_pocket_prompt("yep").expect("non-empty");
assert_eq!(out.text, format!("{}Yep.", short_prefix()));
assert!(out.is_short, "1-word input is short");
assert_eq!(out.max_frames, Some(SHORT_PROMPT_MAX_FRAMES));
assert!(
SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT,
"short cap must be tighter than the upstream default"
);
}
#[test]
fn prepare_prompt_preserves_existing_punctuation() {
let out = prepare_pocket_prompt("yes!").expect("non-empty");
assert_eq!(out.text, format!("{}Yes!", short_prefix())); // exclamation kept
let out = prepare_pocket_prompt("really?").expect("non-empty");
assert_eq!(out.text, format!("{}Really?", short_prefix()));
}
#[test]
fn prepare_prompt_threshold_is_inclusive_at_four_words() {
// 4 words = short (padded + sacrificial + tight max_frames); 5 words
// = long (no padding, no sacrificial, no overrides — upstream
// defaults stand).
let four = prepare_pocket_prompt("one two three four").expect("non-empty");
assert!(four.is_short, "four-word input should be short");
assert!(
four.text.starts_with(' '),
"four-word input should start with the space pad"
);
assert!(
four.text.contains(SACRIFICIAL_PREFIX),
"four-word input should contain the sacrificial prefix"
);
assert_eq!(four.max_frames, Some(SHORT_PROMPT_MAX_FRAMES));
let five = prepare_pocket_prompt("one two three four five").expect("non-empty");
assert!(!five.is_short, "five-word input should NOT be short");
assert!(
!five.text.starts_with(' '),
"five-word input should NOT be padded"
);
assert!(
!five.text.contains(SACRIFICIAL_PREFIX),
"five-word input must not receive the sacrificial prefix"
);
assert_eq!(
five.max_frames, None,
"long inputs must leave sherpa-onnx's max_frames default in place"
);
}
#[test]
fn prepare_prompt_does_not_pad_long_text() {
let long = "This is a longer sentence that the model should handle just fine.";
let out = prepare_pocket_prompt(long).expect("non-empty");
assert!(!out.is_short);
assert!(!out.text.starts_with(' '));
assert!(!out.text.contains(SACRIFICIAL_PREFIX));
assert_eq!(out.max_frames, None);
assert!(out.text.ends_with('.'));
}
#[test]
fn prepare_prompt_collapses_whitespace() {
let out = prepare_pocket_prompt("Hello world\n\nfriend").expect("non-empty");
// 3 words → short → padded + sacrificial. Interior whitespace
// collapsed.
assert_eq!(out.text, format!("{}Hello world friend.", short_prefix()));
}
#[test]
fn prepare_prompt_does_not_double_capitalize_already_uppercase() {
let out = prepare_pocket_prompt("HELLO there").expect("non-empty");
assert_eq!(out.text, format!("{}HELLO there.", short_prefix()));
}
#[test]
fn prepare_prompt_handles_non_ascii_first_letter() {
// Cyrillic lowercase 'д' → uppercase 'Д'. Must not panic / produce
// mojibake.
let out = prepare_pocket_prompt("дa").expect("non-empty");
assert!(out.text.contains("Дa."));
}
#[test]
fn prepare_prompt_inserts_sacrificial_prefix_only_for_short() {
// Pinning the exact ordering: pad, then ". . ", then cleaned text.
// If this ever flips, the trim algorithm's calibration breaks.
let out = prepare_pocket_prompt("I'm happy.").expect("non-empty");
assert!(out.is_short);
let pad = " ".repeat(SHORT_PROMPT_PAD_SPACES);
let expected = format!("{pad}. . I'm happy.");
assert_eq!(out.text, expected);
assert_eq!(SACRIFICIAL_PREFIX, ". . ");
}
// ── build_generation_extra ───────────────────────────────────────────────
//
// These tests pin down a behaviour we've now regressed twice on:
// 1) Not padding/punctuating short inputs → 40 s of "monster breathing"
// (pre-773a2a1).
// 2) Setting `frames_after_eos = 1` on long inputs → clipped leading
// audio of multi-clause sentences, e.g. "Yep, I can hear you. …"
// came out as a static burst (the 773a2a1 regression Tyler hit on
// 2026-05-18 ~14:30 UTC).
//
// The contract we enforce going forward: we **only** override
// `max_frames`, and only for ≤4-word inputs. Every other knob is left
// at sherpa-onnx's documented default (notably `frames_after_eos = 3`).
#[test]
fn build_extra_short_prompt_sets_only_max_frames() {
let prepared = prepare_pocket_prompt("yep").expect("non-empty");
let extra = build_generation_extra(&prepared).expect("short prompts get extra");
// Exactly one key — `max_frames` — and nothing else.
assert_eq!(extra.len(), 1, "extra has unexpected keys: {extra:?}");
assert_eq!(
extra.get("max_frames"),
Some(&serde_json::Value::from(SHORT_PROMPT_MAX_FRAMES))
);
assert!(
!extra.contains_key("frames_after_eos"),
"frames_after_eos must never be set — upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT} is what we want"
);
}
#[test]
fn build_extra_long_prompt_is_none() {
// ≥5 words: no extras at all. This is the key fix for the "first
// 'yep' in 'Yep, I can hear you. …' is static" regression — we
// were previously forcing `frames_after_eos = 1` on this path.
let prepared = prepare_pocket_prompt("Yep, I can hear you.").expect("non-empty");
assert_eq!(
build_generation_extra(&prepared),
None,
"long prompts must not override any LM knob"
);
}
#[test]
fn build_extra_never_lowers_frames_after_eos_for_any_word_count() {
// Sweep a range of prompt lengths and assert the `extra` map (when
// present) never carries a `frames_after_eos` override that's lower
// than the upstream sherpa-onnx default. Implemented as a structural
// check — we just never set the key — but worth a property test in
// case someone reintroduces the override in the future.
let prompts: &[&str] = &[
"hi",
"hi there",
"yes please",
"one two three four",
"one two three four five",
"a slightly longer reply, hopefully fine",
"This is a multi-clause sentence. It has two parts.",
"really really really really really long prompt with lots of words just to be sure",
];
for &p in prompts {
let prepared = prepare_pocket_prompt(p).expect("non-empty");
if let Some(extra) = build_generation_extra(&prepared) {
if let Some(v) = extra.get("frames_after_eos") {
let n = v.as_i64().expect("frames_after_eos should be int");
assert!(
n >= SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT as i64,
"prompt {p:?} set frames_after_eos={n}, below upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT}"
);
}
}
}
}
#[test]
fn short_prompt_max_frames_is_below_upstream_default() {
// Sanity: the override only ever *lowers* the cap, never raises it.
assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT);
// …and is still large enough for a one-to-four-word reply. At Mimi's
// 12.5 Hz frame rate, 100 frames = 8 s, which is roomy.
assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation");
}
// ── trim_leading_cold_start ──────────────────────────────────────────────
/// Build a synthetic buffer shaped like a sacrificial-prefixed Pocket TTS
/// output: a bit of "sacrificial" energy at the head (modelled as
/// alternating ±0.05 — above [`TRIM_SILENCE_THRESHOLD`] so it isn't
/// classified as silence, matching what real probe WAVs look like in the
/// 0–50 ms window), then a flat silence of `gap_ms` ms, then `tail_ms`
/// of "real speech" at peak `tail_peak`.
fn synth_buffer(sacrificial_ms: u32, gap_ms: u32, tail_ms: u32, tail_peak: f32) -> Vec<f32> {
let sr = SAMPLE_RATE as usize;
let mut v = Vec::new();
for i in 0..(sr * sacrificial_ms as usize / 1000) {
v.push(if i % 2 == 0 { 0.05 } else { -0.05 });
}
// Silence gap (true zeros — below TRIM_SILENCE_THRESHOLD).
v.extend(std::iter::repeat_n(0.0_f32, sr * gap_ms as usize / 1000));
// Real speech.
for i in 0..(sr * tail_ms as usize / 1000) {
v.push(if i % 2 == 0 { tail_peak } else { -tail_peak });
}
v
}
#[test]
fn trim_strips_sacrificial_and_keeps_only_speech() {
// 60 ms sacrificial + 100 ms gap + 500 ms speech at peak 0.3.
// After trim, the output is just the speech tail.
let mut v = synth_buffer(60, 100, 500, 0.3);
trim_leading_cold_start(&mut v);
// First sample should be speech (|s| ≥ 0.2). No zero lead-in here
// because tts.rs's `first_append` lead-in handles the device cushion.
assert!(
v[0].abs() > 0.2,
"first sample after trim should be speech, got {}",
v[0]
);
let actual_ms = (v.len() as f32 / SAMPLE_RATE as f32) * 1000.0;
assert!(
(actual_ms - 500.0).abs() < 5.0,
"expected ~500 ms of trimmed audio, got {actual_ms} ms"
);
}
#[test]
fn trim_is_noop_when_no_long_silence_gap_exists() {
// Pure speech: every sample is real (no gap >= 50 ms). Trimmer must
// leave the buffer untouched so we don't truncate the utterance.
let mut v = synth_buffer(0, 0, 600, 0.3);
let before = v.clone();
trim_leading_cold_start(&mut v);
assert_eq!(v, before, "no gap → no trim");
}
#[test]
fn trim_is_noop_when_gap_is_shorter_than_threshold() {
// 40 ms gap is below TRIM_MIN_GAP_SAMPLES (50 ms). Must not trigger.
let mut v = synth_buffer(60, 40, 600, 0.3);
let before = v.clone();
trim_leading_cold_start(&mut v);
assert_eq!(v, before, "sub-threshold gap → no trim");
}
#[test]
fn trim_is_noop_when_gap_is_beyond_max_drop_bound() {
// Gap starts at 1500 ms (past TRIM_MAX_DROP_SAMPLES = 1200 ms).
// This represents an interior pause inside an unusually long
// utterance that slipped past the short-prompt predicate; we must
// not chop the first 1.5 s of real audio.
let mut v = synth_buffer(1500, 200, 400, 0.3);
let before = v.clone();
trim_leading_cold_start(&mut v);
assert_eq!(v, before, "gap past max-drop bound → no trim");
}
#[test]
fn trim_is_noop_on_buffer_smaller_than_scan_start() {
// 20 ms buffer is smaller than TRIM_SCAN_START_SAMPLES (30 ms).
// Trimmer must early-return without panicking.
let mut v = vec![0.5f32; (SAMPLE_RATE as usize * 20) / 1000];
let before = v.clone();
trim_leading_cold_start(&mut v);
assert_eq!(v, before);
}
#[test]
fn trim_constants_use_sane_units() {
// Pin the constants in milliseconds so anyone tuning later can see
// at a glance what they're changing.
assert_eq!(
TRIM_SCAN_START_SAMPLES,
(SAMPLE_RATE as usize * 30) / 1000,
"scan-start should be 30 ms"
);
assert_eq!(
TRIM_MIN_GAP_SAMPLES,
(SAMPLE_RATE as usize * 50) / 1000,
"min-gap should be 50 ms"
);
assert_eq!(
TRIM_MAX_DROP_SAMPLES,
(SAMPLE_RATE as usize * 1200) / 1000,
"max-drop should be 1.2 s"
);
}
}

View File

@@ -5,7 +5,7 @@
//! ```text
//! caller: pipeline.speak("Hello world. How are you?")
//! → bounded sync_channel (TEXT_QUEUE_DEPTH = 8)
//! → tts_worker thread (owns 1 Kokoro engine)
//! → tts_worker thread (owns 1 Pocket TTS engine)
//! 1. Preprocess text
//! 2. Split into sentences
//! 3. Synthesize each sentence individually → f32 PCM
@@ -35,7 +35,7 @@ use std::{
time::Duration,
};
use super::kokoro::{load_text_to_speech, load_voice_style, SAMPLE_RATE};
use super::pocket::{load_text_to_speech, load_voice_style, SAMPLE_RATE, VOICE_FILE_EXT};
use super::preprocessing::{preprocess_for_tts, split_sentences};
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -48,21 +48,62 @@ const TEXT_QUEUE_DEPTH: usize = 8;
/// How long the worker waits on the text channel before checking the shutdown flag.
const RECV_TIMEOUT: Duration = Duration::from_millis(100);
/// Kokoro ignores denoising steps (not a diffusion model). Kept for API compat.
/// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat.
const SYNTH_STEPS: usize = 1;
/// Synthesis speed multiplier. Slightly faster than natural speech.
const SYNTH_SPEED: f32 = 1.05;
/// Volume boost applied after synthesis — Kokoro output is normalized.
/// Start at 1.5 and tune empirically.
const VOLUME_BOOST: f32 = 1.5;
/// Upper bound on per-sentence loudness normalization, in linear scale.
/// −3 dBFS = 10^(−3/20) ≈ 0.708. This is a *ceiling*, not a floor: any
/// sentence whose computed gain would push its peak above this is held at
/// the ceiling, and quieter utterances under the [`MAX_GAIN`] cap may land
/// below it. The ceiling leaves 3 dB of headroom above the loudest sample
/// so the subsequent fade-out and any system mixer gain don't soft-clip.
///
/// Tyler bumped this from the previous −6 dBFS (0.501) to give Pocket TTS
/// output some perceived loudness — the model's reference voice is quieter
/// than Kokoro's was, and the prior target landed normal utterances around
/// −9 dBFS once interior dynamics are accounted for.
const TARGET_PEAK: f32 = 0.707_945_8; // 10f32.powf(-3.0 / 20.0)
/// Fade in/out length in samples (8ms at 24kHz ≈ 192 samples).
/// Eliminates clicks/pops at sentence boundaries.
const FADE_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize;
/// Maximum gain applied by `normalize_for_playback`. Caps amplification on
/// near-silent buffers so a mid-utterance pause or a malformed synth doesn't
/// get amplified to full scale (which would surface any quantization noise).
///
/// Pocket TTS reference-voice output measured ~7.6% peak on a 75-character
/// utterance (`examples/pocket_bench`); a gain of 1/0.076 ≈ 6.6 lands that
/// sample at −6 dBFS and ≈9.3 at −3 dBFS. `8.0` covers normal utterances at
/// the new ceiling while still catching pathological near-silent buffers
/// (which will land at peak ≤ 0.076 × 8 = 0.61 — below the ceiling, which
/// is the intended behaviour).
const MAX_GAIN: f32 = 8.0;
/// Sentence-by-sentence synthesis for lower TTFA (≈200ms vs ≈600ms for 3-sentence batches).
/// Fade-out length in samples (8 ms at 24 kHz ≈ 192 samples).
///
/// Applied only at the *end* of each synthesised sentence to eliminate the
/// click that would otherwise occur when a non-zero waveform terminates
/// abruptly. **No fade-in is applied** — see `apply_fade_out` for the
/// rationale and `examples/pocket_onset_probe.rs` for the measurement that
/// motivated removing the leading fade.
const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize;
/// Length of the zero-sample cushion prepended before each synthesized
/// sentence chunk, so the OS audio device / rodio mixer has a fully-quiet
/// ramp-up window before the real onset hits.
///
/// This used to be applied only before the first sentence of a whole response.
/// That still left later sentence chunks vulnerable to first-syllable clipping
/// when their first phoneme was soft (notably `I'm` / `I've`) and rodio crossed
/// from an explicit silence buffer straight into non-zero speech. 20 ms ≈ 480
/// samples is enough to cover a CoreAudio buffer turnover without being audible
/// as latency. At sentence boundaries this lead-in is budgeted out of the
/// existing inter-sentence pause, so it does not lengthen multi-sentence gaps.
const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize;
/// Sentence-by-sentence synthesis — keeps first-sentence latency low and lets
/// playback of sentence N overlap with synthesis of sentence N+1 (see the
/// lookahead pipelining note in the module doc-comment above).
const BATCH_SIZE: usize = 1;
/// Silence inserted between sentences by the TTS pipeline (seconds).
@@ -87,7 +128,7 @@ pub struct TtsPipeline {
/// Kept alive here so the Arc isn't dropped — the worker holds a clone.
#[allow(dead_code)]
cancel: Arc<AtomicBool>,
/// Voice name (e.g. "af_heart"). Stored for future voice-switching support.
/// Voice name (e.g. "reference_sample"). Stored for future voice-switching support.
#[allow(dead_code)]
voice: String,
/// Worker thread handle — taken on drop to join cleanly.
@@ -97,8 +138,8 @@ pub struct TtsPipeline {
impl TtsPipeline {
/// Spawn the TTS pipeline thread using the default voice.
///
/// `model_dir` must contain the Kokoro model files:
/// `model_quantized.onnx`, `tokenizer.json`, `voices/<name>.bin`
/// `model_dir` must contain the Pocket TTS files declared by `huddle::models`
/// (the five ONNX sessions, the two JSON tables, and `<voice>.wav`).
///
/// `tts_active` is set to `true` while audio is playing and `false` when idle.
/// Pass the same `Arc` to the STT pipeline to gate microphone input.
@@ -112,11 +153,13 @@ impl TtsPipeline {
cancel: Arc<AtomicBool>,
output_device: Option<String>,
) -> Result<Self, String> {
use super::kokoro::DEFAULT_VOICE;
use super::pocket::DEFAULT_VOICE;
Self::new_with_voice(model_dir, tts_active, cancel, DEFAULT_VOICE, output_device)
}
/// Spawn the TTS pipeline thread with a specific voice name (e.g. `"af_heart"`, `"am_michael"`).
/// Spawn the TTS pipeline thread with a specific voice name. Today only the
/// bundled default voice (see `pocket::DEFAULT_VOICE`) is shipped; other
/// names will surface a clear error from `load_voice_style`.
pub fn new_with_voice(
model_dir: PathBuf,
tts_active: Arc<AtomicBool>,
@@ -204,14 +247,14 @@ fn tts_worker(
cancel: Arc<AtomicBool>,
output_device: Option<String>,
) {
// ── 1. Initialise Kokoro engine ───────────────────────────────────────────
// ── 1. Initialise TTS engine ──────────────────────────────────────────────
let model_dir_str = model_dir.to_string_lossy().to_string();
let mut engine = match load_text_to_speech(&model_dir_str) {
let engine = match load_text_to_speech(&model_dir_str) {
Ok(e) => e,
Err(e) => {
eprintln!(
"sprout-desktop: TTS Kokoro init failed (model_dir={}): {e}. TTS disabled.",
"sprout-desktop: TTS engine init failed (model_dir={}): {e}. TTS disabled.",
model_dir.display()
);
drain_until_shutdown(text_rx, &shutdown);
@@ -220,7 +263,7 @@ fn tts_worker(
};
// ── 2. Load voice style ───────────────────────────────────────────────────
let voice_path = model_dir.join(format!("{voice_name}.bin"));
let voice_path = model_dir.join(format!("{voice_name}.{VOICE_FILE_EXT}"));
let style = match load_voice_style(&voice_path) {
Ok(s) => s,
Err(e) => {
@@ -234,9 +277,9 @@ fn tts_worker(
// ── 2b. Warmup inference ─────────────────────────────────────────────────
// The first ONNX inference on any session is significantly slower than
// subsequent ones — it triggers JIT compilation, memory pool allocation,
// and (on CoreML) lazy model compilation. Run a short dummy synthesis and
// discard the output so the first real utterance runs at warm-session speed.
// subsequent ones — it can trigger native session initialization, memory
// pool allocation, and graph-specific caches. Run a short dummy synthesis
// and discard the output so the first real utterance runs at warm-session speed.
{
let t = std::time::Instant::now();
match engine.synth_chunk("warmup", "en", &style, SYNTH_STEPS, SYNTH_SPEED) {
@@ -368,17 +411,23 @@ fn tts_worker(
match engine.synth_chunk(text, "en", &style, SYNTH_STEPS, SYNTH_SPEED) {
Ok(samples) if !samples.is_empty() => {
let mut boosted: Vec<f32> = samples
.iter()
.map(|&s| (s * VOLUME_BOOST).clamp(-1.0, 1.0))
.collect();
apply_fades(&mut boosted);
player.append(SamplesBuffer::new(channels, rate, boosted));
// Insert inter-sentence silence after each synthesized chunk.
player.append(SamplesBuffer::new(channels, rate, silence_buf.clone()));
if first_append {
let mut boosted = normalize_for_playback(samples);
// Fade-out only — fading-in would attenuate the consonant
// onset (see `apply_fade_out` docstring + the
// 2026-05-18 "first little sound is missing" regression).
apply_fade_out(&mut boosted);
// Build one contiguous buffer per synthesized sentence:
// lead-in cushion + audio + trailing gap. Keeping this as
// a single rodio source preserves the original queue/drain
// semantics (one append per sentence) while still giving
// every chunk a quiet device warm-up window.
let was_first = first_append;
let buf =
build_sentence_append_buffer(&mut first_append, boosted, silence_buf.len());
player.append(SamplesBuffer::new(channels, rate, buf));
if was_first {
tts_active.store(true, Ordering::Release);
first_append = false;
}
}
Ok(_) => {}
@@ -439,23 +488,96 @@ fn handle_cancel_or_shutdown(
false
}
/// Apply a short linear fade-in at the start and fade-out at the end of `samples`.
/// Per-sentence peak normalization. Scales the buffer so its loudest sample
/// lands at `TARGET_PEAK` (−6 dBFS), capped at `MAX_GAIN` to avoid amplifying
/// near-silent buffers into quantization noise. Returns the input unchanged on
/// empty input or pure-silence input.
///
/// Uses `FADE_SAMPLES` (8ms) or half the buffer length, whichever is smaller.
/// Eliminates clicks/pops at sentence boundaries.
fn apply_fades(samples: &mut Vec<f32>) {
let len = samples.len();
let fade = FADE_SAMPLES.min(len / 2);
// Fade in: ramp from 0 → 1 over `fade` samples.
for i in 0..fade {
samples[i] *= i as f32 / fade as f32;
/// Why normalize per-sentence rather than apply a fixed boost: Pocket TTS
/// reference-voice output measured ~7.6% peak on one utterance, but per-
/// sentence peak distribution isn't known in advance and may vary with text
/// and any future voice swap. A fixed multiplier either clips loud sentences
/// or under-amplifies quiet ones; normalization adapts.
fn normalize_for_playback(samples: Vec<f32>) -> Vec<f32> {
let peak = samples.iter().fold(0.0_f32, |acc, &s| acc.max(s.abs()));
if peak == 0.0 {
return samples;
}
// Fade out: ramp from 1 → 0 over the last `fade` samples.
let gain = (TARGET_PEAK / peak).min(MAX_GAIN);
samples
.into_iter()
.map(|s| (s * gain).clamp(-1.0, 1.0))
.collect()
}
/// Apply a short linear fade-out at the *end* of `samples`.
///
/// Uses `FADE_OUT_SAMPLES` (8 ms) or half the buffer length, whichever is
/// smaller. Eliminates the click that occurs when a non-zero waveform
/// terminates abruptly at a sentence boundary.
///
/// # Why no fade-in
///
/// An earlier revision (pre 2026-05) symmetrically faded *in* over the same
/// 8 ms window. That swallowed the leading consonant attack on every
/// sentence — Pocket TTS produces real audio energy inside the first
/// millisecond (RMS ≈ 0.02, peak ≈ 0.03 measured across four prompts in
/// `examples/pocket_onset_probe.rs`), and a linear 0→1 ramp over 192 samples
/// scales those onset samples by ≤50 % for the first ~4 ms. The result was
/// the "first little sound or two is missing" regression heard on
/// 2026-05-18.
///
/// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well
/// below the threshold at which a DC-jump would be audible as a click — so
/// no fade-in is needed. The OS audio device gets its quiet ramp-up window
/// from `SENTENCE_LEAD_IN_SAMPLES` instead, inserted as pure silence before
/// each sentence buffer.
fn apply_fade_out(samples: &mut [f32]) {
let len = samples.len();
let fade = FADE_OUT_SAMPLES.min(len / 2);
for i in 0..fade {
samples[len - 1 - i] *= i as f32 / fade as f32;
}
}
/// Build the single buffer appended to the rodio `Player` for one synthesised
/// sentence.
///
/// Every sentence chunk gets a short lead-in pad immediately before its audio.
/// This matters for chunks that start with soft first phonemes (`I'm`, `I've`):
/// the sacrificial-prefix trim intentionally returns audio whose first sample is
/// already speech, so the playback layer must provide the device/mixer cushion.
/// To keep the audible gap unchanged, the trailing silence after this chunk is
/// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`):
/// sentence N contributes 80 ms of post-speech silence and sentence N+1
/// contributes the remaining 20 ms of pre-speech cushion.
///
/// The lead-in, audio, and trailing silence are concatenated into one
/// `SamplesBuffer` before appending. This keeps rodio's queue shape at one
/// tracked source per synthesized sentence, avoiding source-boundary/drain
/// regressions from enqueueing the lead-in, audio, and tail as separate sounds.
///
/// `first_append` is still accepted and flipped on the first call because the
/// worker uses it to decide when actual playback has been queued and when it is
/// safe to set `tts_active` for echo gating.
fn build_sentence_append_buffer(
first_append: &mut bool,
boosted: Vec<f32>,
silence_buf_len: usize,
) -> Vec<f32> {
if *first_append {
*first_append = false;
}
let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES);
let mut buf =
Vec::with_capacity(SENTENCE_LEAD_IN_SAMPLES + boosted.len() + trailing_silence_len);
buf.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES));
buf.extend(boosted);
buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len));
buf
}
// drain_until_shutdown lives in super (huddle/mod.rs) — shared with stt.rs.
use super::drain_until_shutdown;
@@ -1001,28 +1123,241 @@ mod tests {
);
}
// ── apply_fades tests ─────────────────────────────────────────────────────
// ── apply_fade_out tests ──────────────────────────────────────────────────
/// The fade-out half of the old `apply_fades`: last sample is silenced
/// and the ramp is monotonic. Mid-buffer must be untouched.
#[test]
fn apply_fades_short_buffer() {
fn apply_fade_out_short_buffer() {
let mut samples = vec![1.0f32; 10];
apply_fades(&mut samples);
assert_eq!(samples[0], 0.0);
assert_eq!(samples[9], 0.0);
assert!(samples[5] > 0.5);
apply_fade_out(&mut samples);
assert_eq!(samples[9], 0.0, "last sample should be silenced");
assert!(samples[5] > 0.5, "mid-buffer should be near-untouched");
}
/// REGRESSION (2026-05-18): the *first* samples must NOT be attenuated.
/// An earlier `apply_fades` symmetrically faded in over 8 ms which
/// swallowed the consonant onset of every sentence.
/// Lock in: samples[0..FADE_OUT_SAMPLES] are byte-equal to input.
#[test]
fn apply_fade_out_does_not_touch_leading_samples() {
// Input long enough that fade window doesn't overlap (≫ 2× fade).
let n = FADE_OUT_SAMPLES * 4;
let input: Vec<f32> = (0..n).map(|i| 0.5 + (i as f32) * 1e-4).collect();
let mut samples = input.clone();
apply_fade_out(&mut samples);
for i in 0..FADE_OUT_SAMPLES {
assert_eq!(
samples[i], input[i],
"leading sample {i} must not be attenuated (was {} → {})",
input[i], samples[i]
);
}
// And the trailing fade still works.
assert_eq!(samples[n - 1], 0.0);
}
#[test]
fn apply_fades_empty_buffer() {
fn apply_fade_out_empty_buffer() {
let mut samples: Vec<f32> = vec![];
apply_fades(&mut samples);
apply_fade_out(&mut samples);
assert!(samples.is_empty());
}
#[test]
fn apply_fades_single_sample() {
fn apply_fade_out_single_sample() {
// fade = min(FADE_OUT_SAMPLES, len/2) = 0, so nothing changes.
let mut samples = vec![1.0f32];
apply_fades(&mut samples);
apply_fade_out(&mut samples);
assert_eq!(samples[0], 1.0);
}
/// Sanity-check the per-sentence cushion length: 20 ms at 24 kHz must
/// land at exactly 480 samples. This is a const computation, so the
/// real value of this test is documenting *why* 20 ms was chosen — it
/// covers a typical CoreAudio buffer turnover (256–1024 samples)
/// without being audible as user-facing latency.
#[test]
fn sentence_lead_in_is_sane() {
assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz");
}
// ── build_sentence_append_buffer tests ───────────────────────────────────
/// REGRESSION: every chunk needs an onset cushion; trimmed short chunks
/// can start with speech at sample 0.
#[test]
fn lead_in_pad_is_present_for_every_sentence_chunk() {
const SENTENCE_AUDIO_LEN: usize = 1000;
const SILENCE_BUF_LEN: usize = 2400; // 100 ms at 24 kHz, like production
const N_SENTENCES: usize = 5;
let mut first = true;
for _ in 0..N_SENTENCES {
let buf = build_sentence_append_buffer(
&mut first,
vec![0.5_f32; SENTENCE_AUDIO_LEN],
SILENCE_BUF_LEN,
);
assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN);
assert!(
buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0),
"lead-in pad must be pure silence"
);
assert!(
buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN]
.iter()
.all(|&s| s == 0.5),
"sentence audio must immediately follow the lead-in"
);
assert!(
buf[SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN..]
.iter()
.all(|&s| s == 0.0),
"trailing gap must be pure silence"
);
}
assert!(!first, "first_append flag must be cleared after first call");
}
/// `first_append` still flips on the first call for `tts_active` gating.
#[test]
fn build_sentence_append_buffer_flips_first_append() {
let mut first = true;
let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400);
assert!(!first, "first call must flip the flag");
// Subsequent call: still has a per-sentence lead-in, flag stays false.
let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400);
assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0));
assert!(!first);
}
/// Leading silence is exactly the lead-in; no pre-audio gap is double-counted.
#[test]
fn first_sentence_leading_silence_is_exactly_lead_in() {
let mut first = true;
let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400);
assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0));
assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5);
}
/// Tail silence plus the next lead-in preserves the 100 ms sentence gap.
#[test]
fn sentence_gap_budget_is_preserved() {
let mut first = true;
let silence_buf_len = 2400;
let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len);
let second_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len);
let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..];
let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES];
assert_eq!(first_tail.len(), silence_buf_len - SENTENCE_LEAD_IN_SAMPLES);
assert_eq!(second_lead.len(), SENTENCE_LEAD_IN_SAMPLES);
assert_eq!(first_tail.len() + second_lead.len(), silence_buf_len);
}
/// Regression guard: one contiguous rodio source per synthesized sentence.
#[test]
fn sentence_append_buffer_is_one_contiguous_source() {
let mut first = true;
let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400);
assert_eq!(buf.len(), 2400 + 100);
assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0));
assert!(
buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + 100]
.iter()
.all(|&s| s == 0.5)
);
}
// ── normalize_for_playback tests ──────────────────────────────────────────
/// A moderately quiet buffer (peak comfortably under MAX_GAIN reach of
/// TARGET_PEAK) is scaled up to exactly TARGET_PEAK. With TARGET_PEAK at
/// −3 dBFS (0.708) and MAX_GAIN at 8.0, the gain saturation point is
/// peak = 0.708 / 8.0 ≈ 0.0885 — so a 0.1-peak buffer is in the linear
/// region and gets normalized cleanly.
#[test]
fn normalize_for_playback_hits_target_on_quiet_buffer() {
// peak 0.1 ⇒ ideal gain 7.08, under MAX_GAIN (8.0), so target hit.
let mut input: Vec<f32> = (0..100).map(|i| 0.1 * (i as f32 / 100.0)).collect();
input.push(0.1); // ensure exact peak
let out = normalize_for_playback(input);
let peak = out.iter().fold(0.0_f32, |a, &s| a.max(s.abs()));
assert!(
(peak - TARGET_PEAK).abs() < 1e-3,
"expected peak ~{TARGET_PEAK}, got {peak}"
);
}
/// A buffer whose ideal gain exceeds MAX_GAIN gets clamped — the peak
/// lands at input_peak × MAX_GAIN, below TARGET_PEAK. With the new
/// −3 dBFS target this captures the Pocket-bench-typical case
/// (input peak 0.076 → ideal gain ≈ 9.3 > MAX_GAIN, so we clamp).
#[test]
fn normalize_for_playback_clamps_at_max_gain_below_target() {
let mut input: Vec<f32> = (0..100).map(|i| 0.076 * (i as f32 / 100.0)).collect();
input.push(0.076);
let out = normalize_for_playback(input);
let peak = out.iter().fold(0.0_f32, |a, &s| a.max(s.abs()));
let expected = 0.076 * MAX_GAIN;
assert!(
(peak - expected).abs() < 1e-3,
"expected peak ~{expected}, got {peak}"
);
assert!(
peak < TARGET_PEAK,
"clamped peak {peak} should be below TARGET_PEAK {TARGET_PEAK}"
);
}
/// A near-silent buffer would need a huge gain to reach TARGET_PEAK;
/// `MAX_GAIN` caps the amplification so we don't bring quantization noise
/// up to full scale.
#[test]
fn normalize_for_playback_caps_gain_on_near_silent_buffer() {
// peak 0.001 ⇒ ideal gain 501; MAX_GAIN caps it at 8.0, so the
// resulting peak is 0.001 × 8.0 = 0.008, NOT TARGET_PEAK.
let input = vec![0.001_f32, -0.001, 0.001];
let out = normalize_for_playback(input);
let peak = out.iter().fold(0.0_f32, |a, &s| a.max(s.abs()));
assert!(
(peak - 0.008).abs() < 1e-6,
"expected peak 0.008 (gain-capped), got {peak}"
);
assert!(peak < TARGET_PEAK);
}
/// A pure-silence buffer is returned unchanged — no division by zero, no
/// amplification of nothing.
#[test]
fn normalize_for_playback_silence_is_unchanged() {
let input = vec![0.0_f32; 16];
let out = normalize_for_playback(input);
assert!(out.iter().all(|&s| s == 0.0));
assert_eq!(out.len(), 16);
}
/// Empty input round-trips to empty output. No panic on `peak == 0` path.
#[test]
fn normalize_for_playback_empty_buffer() {
let out = normalize_for_playback(Vec::new());
assert!(out.is_empty());
}
/// A buffer that would otherwise clip after gain is hard-clamped to ±1.0.
/// Belt-and-suspenders: `gain * peak` shouldn't exceed TARGET_PEAK by
/// construction, but the `.clamp(-1.0, 1.0)` is still asserted here in case
/// future changes loosen TARGET_PEAK or MAX_GAIN past full scale.
#[test]
fn normalize_for_playback_clamps_to_full_scale() {
let input = vec![10.0_f32, -10.0]; // already past full scale before gain
let out = normalize_for_playback(input);
assert!(out.iter().all(|&s| s.abs() <= 1.0));
}
}

View File

@@ -395,10 +395,10 @@ pub fn run() {
// Pre-download voice models in the background so they're ready
// when the user starts their first huddle. Idempotent — no-op if
// already downloaded. ~187 MB total (~100 MB Parakeet STT + ~87 MB Kokoro).
// already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS).
if let Some(mgr) = huddle::models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
mgr.start_kokoro_download(state.http_client.clone());
mgr.start_tts_download(state.http_client.clone());
}
// Register PTT global shortcut (Ctrl+Space).

View File

@@ -66,7 +66,7 @@ export function HuddleBar({ className }: HuddleBarProps) {
const [agentAddError, setAgentAddError] = React.useState<string | null>(null);
const [modelStatus, setModelStatus] = React.useState<{
stt: string;
kokoro: string;
tts: string;
} | null>(null);
// Huddle state: event-driven + 10s fallback poll.
React.useEffect(() => {
@@ -133,13 +133,13 @@ export function HuddleBar({ className }: HuddleBarProps) {
try {
const status = await invoke<{
stt: unknown;
kokoro: unknown;
tts: unknown;
}>("get_model_status");
if (cancelled) return;
setModelStatus({
stt: fmt(status.stt),
kokoro: fmt(status.kokoro),
tts: fmt(status.tts),
});
} catch {
// best-effort
@@ -228,14 +228,14 @@ export function HuddleBar({ className }: HuddleBarProps) {
{/* Model download progress */}
{modelStatus &&
(modelStatus.stt !== "ready" || modelStatus.kokoro !== "ready") && (
(modelStatus.stt !== "ready" || modelStatus.tts !== "ready") && (
<output className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="animate-pulse">
{modelStatus.stt !== "ready" && modelStatus.kokoro !== "ready"
? `Voice models: STT ${modelStatus.stt}, TTS ${modelStatus.kokoro}`
{modelStatus.stt !== "ready" && modelStatus.tts !== "ready"
? `Voice models: STT ${modelStatus.stt}, TTS ${modelStatus.tts}`
: modelStatus.stt !== "ready"
? `STT model: ${modelStatus.stt}`
: `TTS model: ${modelStatus.kokoro}`}
: `TTS model: ${modelStatus.tts}`}
</span>
</output>
)}
@@ -464,8 +464,8 @@ export function HuddleBar({ className }: HuddleBarProps) {
modelStatus.stt !== "ready" &&
`, STT model ${modelStatus.stt}`}
{modelStatus &&
modelStatus.kokoro !== "ready" &&
`, TTS model ${modelStatus.kokoro}`}
modelStatus.tts !== "ready" &&
`, TTS model ${modelStatus.tts}`}
</output>
</div>
);