diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | CONTEXT.md | 15 | ||||
| -rw-r--r-- | Cargo.lock | 359 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | build.rs | 52 | ||||
| -rw-r--r-- | src/commands/ai.rs | 5 | ||||
| -rw-r--r-- | src/commands/tts.rs | 14 | ||||
| -rw-r--r-- | src/events/mod.rs | 34 | ||||
| -rw-r--r-- | src/lang.rs | 7 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/tts.rs | 384 | ||||
| -rw-r--r-- | tests/tts_smoke.rs | 54 |
12 files changed, 762 insertions, 175 deletions
| @@ -23,7 +23,7 @@ src/ | |||
| 23 | ├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message | 23 | ├── events/mod.rs — handle_ready, handle_voice_state_update, handle_message |
| 24 | ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) | 24 | ├── openrouter/mod.rs — OpenRouterClient (chat, chat_with_history) |
| 25 | ├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) | 25 | ├── tasks/mod.rs — cron_scheduler_loop (30s polling, stub exec bodies) |
| 26 | ├── tts.rs — synthesize(): Piper subprocess → Google TTS fallback | 26 | ├── tts.rs — TtsState (piper-rs in-process, DashMap cache, pre-gen, resampler, WAV encoder) |
| 27 | └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input | 27 | └── voice/mod.rs — VoiceManager (connect, disconnect, get_call), mp3_to_input |
| 28 | ``` | 28 | ``` |
| 29 | 29 | ||
| @@ -35,6 +35,10 @@ src/ | |||
| 35 | - **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` | 35 | - **Shared state**: `Data` struct in `main.rs` — passed as poise `user_data` |
| 36 | - **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>` | 36 | - **Commands**: each submodule exports `pub fn commands() -> Vec<poise::Command<Data, Error>>` |
| 37 | - **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`) | 37 | - **Temp path**: always use `crate::config::TEMP_PATH` (resolves to `/tmp/senator_sperma`) |
| 38 | - **TTS**: piper-rs in-process (no subprocess). `TtsState` in `Data` holds `OnceLock<Mutex<Piper>>` + `DashMap` cache. Synthesize via `data.tts.synthesize(text)`. Pre-generates 6 event phrases per user on first voice event. No Google TTS fallback. | ||
| 39 | |||
| 40 | ## Build | ||
| 41 | - `build.rs` — compiles C stubs for espeak-rs-sys missing sonic/audio_object symbols | ||
| 38 | 42 | ||
| 39 | ## Stubs to Fill (see COMPARISON.md) | 43 | ## Stubs to Fill (see COMPARISON.md) |
| 40 | - `commands/music.rs` — `/play` is placeholder | 44 | - `commands/music.rs` — `/play` is placeholder |
| @@ -32,3 +32,18 @@ holds no implementation decisions or specs (those live in `docs/adr/`). | |||
| 32 | afk). Paged at 10 entries, descending by default. | 32 | afk). Paged at 10 entries, descending by default. |
| 33 | - **Tracked guild** — the guild containing `status_channel_id`. Message counts | 33 | - **Tracked guild** — the guild containing `status_channel_id`. Message counts |
| 34 | are only tracked in this guild. | 34 | are only tracked in this guild. |
| 35 | |||
| 36 | ## TTS | ||
| 37 | |||
| 38 | - **Synthesize** — convert text to audio via local Piper TTS (ONNX model | ||
| 39 | loaded in-process via `piper-rs`). No external fallback. Output is f32 PCM | ||
| 40 | at the model's native sample rate (typically 22050 Hz mono), resampled to | ||
| 41 | 48 kHz stereo via inline linear interpolation before playback. Songbird does | ||
| 42 | not resample — playing un-resampled audio results in wrong playback speed. | ||
| 43 | - **TtsState** — struct held in poise `Data`: `OnceCell<Mutex<Piper>>` for the | ||
| 44 | model instance, `DashMap<String, (Vec<u8>, Instant)>` for the text→audio cache. | ||
| 45 | Piper loaded lazily on first TTS call. Cache keyed by full phrase string, | ||
| 46 | evicted by TTL. No Google TTS fallback. | ||
| 47 | - **Pre-generation** — on first voice event for a user, synthesize all 6 event | ||
| 48 | phrases (join/leave/deafen/undeafen/mute/unmute) with their display name and | ||
| 49 | cache them. ~120ms first time, instant after. Lazy — no startup cost. | ||
| @@ -174,7 +174,7 @@ dependencies = [ | |||
| 174 | "anyhow", | 174 | "anyhow", |
| 175 | "arrayvec", | 175 | "arrayvec", |
| 176 | "log", | 176 | "log", |
| 177 | "nom", | 177 | "nom 8.0.0", |
| 178 | "num-rational", | 178 | "num-rational", |
| 179 | "v_frame", | 179 | "v_frame", |
| 180 | ] | 180 | ] |
| @@ -223,12 +223,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 223 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | 223 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 224 | 224 | ||
| 225 | [[package]] | 225 | [[package]] |
| 226 | name = "base64" | ||
| 227 | version = "0.23.1" | ||
| 228 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 229 | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | ||
| 230 | |||
| 231 | [[package]] | ||
| 226 | name = "base64ct" | 232 | name = "base64ct" |
| 227 | version = "1.8.3" | 233 | version = "1.8.3" |
| 228 | source = "registry+https://github.com/rust-lang/crates.io-index" | 234 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 229 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | 235 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" |
| 230 | 236 | ||
| 231 | [[package]] | 237 | [[package]] |
| 238 | name = "bindgen" | ||
| 239 | version = "0.69.5" | ||
| 240 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 241 | checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" | ||
| 242 | dependencies = [ | ||
| 243 | "bitflags 2.13.0", | ||
| 244 | "cexpr", | ||
| 245 | "clang-sys", | ||
| 246 | "itertools", | ||
| 247 | "lazy_static", | ||
| 248 | "lazycell", | ||
| 249 | "log", | ||
| 250 | "prettyplease", | ||
| 251 | "proc-macro2", | ||
| 252 | "quote", | ||
| 253 | "regex", | ||
| 254 | "rustc-hash 1.1.0", | ||
| 255 | "shlex 1.3.0", | ||
| 256 | "syn 2.0.118", | ||
| 257 | "which", | ||
| 258 | ] | ||
| 259 | |||
| 260 | [[package]] | ||
| 232 | name = "bit-set" | 261 | name = "bit-set" |
| 233 | version = "0.8.0" | 262 | version = "0.8.0" |
| 234 | source = "registry+https://github.com/rust-lang/crates.io-index" | 263 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -370,7 +399,16 @@ dependencies = [ | |||
| 370 | "find-msvc-tools", | 399 | "find-msvc-tools", |
| 371 | "jobserver", | 400 | "jobserver", |
| 372 | "libc", | 401 | "libc", |
| 373 | "shlex", | 402 | "shlex 2.0.1", |
| 403 | ] | ||
| 404 | |||
| 405 | [[package]] | ||
| 406 | name = "cexpr" | ||
| 407 | version = "0.6.0" | ||
| 408 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 409 | checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" | ||
| 410 | dependencies = [ | ||
| 411 | "nom 7.1.3", | ||
| 374 | ] | 412 | ] |
| 375 | 413 | ||
| 376 | [[package]] | 414 | [[package]] |
| @@ -466,6 +504,17 @@ dependencies = [ | |||
| 466 | ] | 504 | ] |
| 467 | 505 | ||
| 468 | [[package]] | 506 | [[package]] |
| 507 | name = "clang-sys" | ||
| 508 | version = "1.9.1" | ||
| 509 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 510 | checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" | ||
| 511 | dependencies = [ | ||
| 512 | "glob", | ||
| 513 | "libc", | ||
| 514 | "libloading", | ||
| 515 | ] | ||
| 516 | |||
| 517 | [[package]] | ||
| 469 | name = "cmake" | 518 | name = "cmake" |
| 470 | version = "0.1.58" | 519 | version = "0.1.58" |
| 471 | source = "registry+https://github.com/rust-lang/crates.io-index" | 520 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -926,7 +975,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 926 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | 975 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" |
| 927 | dependencies = [ | 976 | dependencies = [ |
| 928 | "const-oid", | 977 | "const-oid", |
| 929 | "pem-rfc7468", | 978 | "pem-rfc7468 0.7.0", |
| 979 | "zeroize", | ||
| 980 | ] | ||
| 981 | |||
| 982 | [[package]] | ||
| 983 | name = "der" | ||
| 984 | version = "0.8.2" | ||
| 985 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 986 | checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" | ||
| 987 | dependencies = [ | ||
| 988 | "pem-rfc7468 1.0.0", | ||
| 930 | "zeroize", | 989 | "zeroize", |
| 931 | ] | 990 | ] |
| 932 | 991 | ||
| @@ -1086,7 +1145,7 @@ version = "0.16.9" | |||
| 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1145 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1087 | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | 1146 | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" |
| 1088 | dependencies = [ | 1147 | dependencies = [ |
| 1089 | "der", | 1148 | "der 0.7.10", |
| 1090 | "digest 0.10.7", | 1149 | "digest 0.10.7", |
| 1091 | "elliptic-curve", | 1150 | "elliptic-curve", |
| 1092 | "rfc6979", | 1151 | "rfc6979", |
| @@ -1147,7 +1206,7 @@ dependencies = [ | |||
| 1147 | "generic-array", | 1206 | "generic-array", |
| 1148 | "group", | 1207 | "group", |
| 1149 | "hkdf 0.12.4", | 1208 | "hkdf 0.12.4", |
| 1150 | "pem-rfc7468", | 1209 | "pem-rfc7468 0.7.0", |
| 1151 | "pkcs8", | 1210 | "pkcs8", |
| 1152 | "rand_core 0.6.4", | 1211 | "rand_core 0.6.4", |
| 1153 | "sec1", | 1212 | "sec1", |
| @@ -1230,6 +1289,26 @@ dependencies = [ | |||
| 1230 | ] | 1289 | ] |
| 1231 | 1290 | ||
| 1232 | [[package]] | 1291 | [[package]] |
| 1292 | name = "espeak-rs" | ||
| 1293 | version = "0.2.0" | ||
| 1294 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1295 | checksum = "89534f20ce914016aae6cc63e9476aad41cee3e96b48e712e1702caf24c0c538" | ||
| 1296 | dependencies = [ | ||
| 1297 | "espeak-rs-sys", | ||
| 1298 | ] | ||
| 1299 | |||
| 1300 | [[package]] | ||
| 1301 | name = "espeak-rs-sys" | ||
| 1302 | version = "0.2.0" | ||
| 1303 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1304 | checksum = "2d45d148019084e930df6cc3964a58c4c211342451ec5d3c328d8a6cc6b3464d" | ||
| 1305 | dependencies = [ | ||
| 1306 | "bindgen", | ||
| 1307 | "cmake", | ||
| 1308 | "glob", | ||
| 1309 | ] | ||
| 1310 | |||
| 1311 | [[package]] | ||
| 1233 | name = "etcetera" | 1312 | name = "etcetera" |
| 1234 | version = "0.11.0" | 1313 | version = "0.11.0" |
| 1235 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1314 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1827,6 +1906,21 @@ dependencies = [ | |||
| 1827 | ] | 1906 | ] |
| 1828 | 1907 | ||
| 1829 | [[package]] | 1908 | [[package]] |
| 1909 | name = "hmac-sha256" | ||
| 1910 | version = "1.1.14" | ||
| 1911 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1912 | checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" | ||
| 1913 | |||
| 1914 | [[package]] | ||
| 1915 | name = "home" | ||
| 1916 | version = "0.5.12" | ||
| 1917 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1918 | checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" | ||
| 1919 | dependencies = [ | ||
| 1920 | "windows-sys 0.61.2", | ||
| 1921 | ] | ||
| 1922 | |||
| 1923 | [[package]] | ||
| 1830 | name = "hpke-rs" | 1924 | name = "hpke-rs" |
| 1831 | version = "0.6.1" | 1925 | version = "0.6.1" |
| 1832 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1926 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2012,7 +2106,7 @@ version = "0.1.20" | |||
| 2012 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2106 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2013 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | 2107 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" |
| 2014 | dependencies = [ | 2108 | dependencies = [ |
| 2015 | "base64", | 2109 | "base64 0.22.1", |
| 2016 | "bytes", | 2110 | "bytes", |
| 2017 | "futures-channel", | 2111 | "futures-channel", |
| 2018 | "futures-util", | 2112 | "futures-util", |
| @@ -2323,6 +2417,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2323 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | 2417 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" |
| 2324 | 2418 | ||
| 2325 | [[package]] | 2419 | [[package]] |
| 2420 | name = "lazycell" | ||
| 2421 | version = "1.3.0" | ||
| 2422 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2423 | checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" | ||
| 2424 | |||
| 2425 | [[package]] | ||
| 2326 | name = "lebe" | 2426 | name = "lebe" |
| 2327 | version = "0.5.3" | 2427 | version = "0.5.3" |
| 2328 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2428 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2567,6 +2667,16 @@ dependencies = [ | |||
| 2567 | ] | 2667 | ] |
| 2568 | 2668 | ||
| 2569 | [[package]] | 2669 | [[package]] |
| 2670 | name = "libloading" | ||
| 2671 | version = "0.8.9" | ||
| 2672 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2673 | checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" | ||
| 2674 | dependencies = [ | ||
| 2675 | "cfg-if", | ||
| 2676 | "windows-link", | ||
| 2677 | ] | ||
| 2678 | |||
| 2679 | [[package]] | ||
| 2570 | name = "libm" | 2680 | name = "libm" |
| 2571 | version = "0.2.16" | 2681 | version = "0.2.16" |
| 2572 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2682 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2596,6 +2706,12 @@ dependencies = [ | |||
| 2596 | 2706 | ||
| 2597 | [[package]] | 2707 | [[package]] |
| 2598 | name = "linux-raw-sys" | 2708 | name = "linux-raw-sys" |
| 2709 | version = "0.4.15" | ||
| 2710 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2711 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | ||
| 2712 | |||
| 2713 | [[package]] | ||
| 2714 | name = "linux-raw-sys" | ||
| 2599 | version = "0.12.1" | 2715 | version = "0.12.1" |
| 2600 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2716 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2601 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | 2717 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" |
| @@ -2652,6 +2768,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2652 | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" | 2768 | checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" |
| 2653 | 2769 | ||
| 2654 | [[package]] | 2770 | [[package]] |
| 2771 | name = "lzma-rust2" | ||
| 2772 | version = "0.15.8" | ||
| 2773 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2774 | checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" | ||
| 2775 | |||
| 2776 | [[package]] | ||
| 2655 | name = "markup5ever" | 2777 | name = "markup5ever" |
| 2656 | version = "0.39.0" | 2778 | version = "0.39.0" |
| 2657 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2779 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2739,6 +2861,12 @@ dependencies = [ | |||
| 2739 | ] | 2861 | ] |
| 2740 | 2862 | ||
| 2741 | [[package]] | 2863 | [[package]] |
| 2864 | name = "minimal-lexical" | ||
| 2865 | version = "0.2.1" | ||
| 2866 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2867 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" | ||
| 2868 | |||
| 2869 | [[package]] | ||
| 2742 | name = "miniz_oxide" | 2870 | name = "miniz_oxide" |
| 2743 | version = "0.8.9" | 2871 | version = "0.8.9" |
| 2744 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2872 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2820,6 +2948,36 @@ dependencies = [ | |||
| 2820 | ] | 2948 | ] |
| 2821 | 2949 | ||
| 2822 | [[package]] | 2950 | [[package]] |
| 2951 | name = "ndarray" | ||
| 2952 | version = "0.16.1" | ||
| 2953 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2954 | checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" | ||
| 2955 | dependencies = [ | ||
| 2956 | "matrixmultiply", | ||
| 2957 | "num-complex", | ||
| 2958 | "num-integer", | ||
| 2959 | "num-traits", | ||
| 2960 | "portable-atomic", | ||
| 2961 | "portable-atomic-util", | ||
| 2962 | "rawpointer", | ||
| 2963 | ] | ||
| 2964 | |||
| 2965 | [[package]] | ||
| 2966 | name = "ndarray" | ||
| 2967 | version = "0.17.2" | ||
| 2968 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2969 | checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" | ||
| 2970 | dependencies = [ | ||
| 2971 | "matrixmultiply", | ||
| 2972 | "num-complex", | ||
| 2973 | "num-integer", | ||
| 2974 | "num-traits", | ||
| 2975 | "portable-atomic", | ||
| 2976 | "portable-atomic-util", | ||
| 2977 | "rawpointer", | ||
| 2978 | ] | ||
| 2979 | |||
| 2980 | [[package]] | ||
| 2823 | name = "new_debug_unreachable" | 2981 | name = "new_debug_unreachable" |
| 2824 | version = "1.0.6" | 2982 | version = "1.0.6" |
| 2825 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2983 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2839,6 +2997,16 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" | |||
| 2839 | 2997 | ||
| 2840 | [[package]] | 2998 | [[package]] |
| 2841 | name = "nom" | 2999 | name = "nom" |
| 3000 | version = "7.1.3" | ||
| 3001 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3002 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" | ||
| 3003 | dependencies = [ | ||
| 3004 | "memchr", | ||
| 3005 | "minimal-lexical", | ||
| 3006 | ] | ||
| 3007 | |||
| 3008 | [[package]] | ||
| 3009 | name = "nom" | ||
| 2842 | version = "8.0.0" | 3010 | version = "8.0.0" |
| 2843 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3011 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2844 | checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" | 3012 | checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" |
| @@ -3122,6 +3290,30 @@ dependencies = [ | |||
| 3122 | ] | 3290 | ] |
| 3123 | 3291 | ||
| 3124 | [[package]] | 3292 | [[package]] |
| 3293 | name = "ort" | ||
| 3294 | version = "2.0.0-rc.12" | ||
| 3295 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3296 | checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" | ||
| 3297 | dependencies = [ | ||
| 3298 | "ndarray 0.17.2", | ||
| 3299 | "ort-sys", | ||
| 3300 | "smallvec", | ||
| 3301 | "tracing", | ||
| 3302 | "ureq", | ||
| 3303 | ] | ||
| 3304 | |||
| 3305 | [[package]] | ||
| 3306 | name = "ort-sys" | ||
| 3307 | version = "2.0.0-rc.12" | ||
| 3308 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3309 | checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" | ||
| 3310 | dependencies = [ | ||
| 3311 | "hmac-sha256", | ||
| 3312 | "lzma-rust2", | ||
| 3313 | "ureq", | ||
| 3314 | ] | ||
| 3315 | |||
| 3316 | [[package]] | ||
| 3125 | name = "owned_ttf_parser" | 3317 | name = "owned_ttf_parser" |
| 3126 | version = "0.25.1" | 3318 | version = "0.25.1" |
| 3127 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3319 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3247,6 +3439,15 @@ dependencies = [ | |||
| 3247 | ] | 3439 | ] |
| 3248 | 3440 | ||
| 3249 | [[package]] | 3441 | [[package]] |
| 3442 | name = "pem-rfc7468" | ||
| 3443 | version = "1.0.0" | ||
| 3444 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3445 | checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" | ||
| 3446 | dependencies = [ | ||
| 3447 | "base64ct", | ||
| 3448 | ] | ||
| 3449 | |||
| 3450 | [[package]] | ||
| 3250 | name = "percent-encoding" | 3451 | name = "percent-encoding" |
| 3251 | version = "2.3.2" | 3452 | version = "2.3.2" |
| 3252 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3453 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3398,12 +3599,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3398 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" | 3599 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" |
| 3399 | 3600 | ||
| 3400 | [[package]] | 3601 | [[package]] |
| 3602 | name = "piper-rs" | ||
| 3603 | version = "0.2.0" | ||
| 3604 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3605 | checksum = "b53db829904e6c2959ee6ff4922401a9eccd23c7bd6582bdd8ebec627a953c9b" | ||
| 3606 | dependencies = [ | ||
| 3607 | "espeak-rs", | ||
| 3608 | "ndarray 0.16.1", | ||
| 3609 | "ort", | ||
| 3610 | "serde", | ||
| 3611 | "serde_json", | ||
| 3612 | ] | ||
| 3613 | |||
| 3614 | [[package]] | ||
| 3401 | name = "pkcs8" | 3615 | name = "pkcs8" |
| 3402 | version = "0.10.2" | 3616 | version = "0.10.2" |
| 3403 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3617 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3404 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | 3618 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" |
| 3405 | dependencies = [ | 3619 | dependencies = [ |
| 3406 | "der", | 3620 | "der 0.7.10", |
| 3407 | "spki", | 3621 | "spki", |
| 3408 | ] | 3622 | ] |
| 3409 | 3623 | ||
| @@ -3544,6 +3758,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3544 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" | 3758 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" |
| 3545 | 3759 | ||
| 3546 | [[package]] | 3760 | [[package]] |
| 3761 | name = "prettyplease" | ||
| 3762 | version = "0.2.37" | ||
| 3763 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3764 | checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" | ||
| 3765 | dependencies = [ | ||
| 3766 | "proc-macro2", | ||
| 3767 | "syn 2.0.118", | ||
| 3768 | ] | ||
| 3769 | |||
| 3770 | [[package]] | ||
| 3547 | name = "primal-check" | 3771 | name = "primal-check" |
| 3548 | version = "0.3.4" | 3772 | version = "0.3.4" |
| 3549 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3773 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3663,7 +3887,7 @@ dependencies = [ | |||
| 3663 | "pin-project-lite", | 3887 | "pin-project-lite", |
| 3664 | "quinn-proto", | 3888 | "quinn-proto", |
| 3665 | "quinn-udp", | 3889 | "quinn-udp", |
| 3666 | "rustc-hash", | 3890 | "rustc-hash 2.1.2", |
| 3667 | "rustls 0.23.40", | 3891 | "rustls 0.23.40", |
| 3668 | "socket2 0.6.4", | 3892 | "socket2 0.6.4", |
| 3669 | "thiserror 2.0.18", | 3893 | "thiserror 2.0.18", |
| @@ -3683,7 +3907,7 @@ dependencies = [ | |||
| 3683 | "lru-slab", | 3907 | "lru-slab", |
| 3684 | "rand 0.9.4", | 3908 | "rand 0.9.4", |
| 3685 | "ring", | 3909 | "ring", |
| 3686 | "rustc-hash", | 3910 | "rustc-hash 2.1.2", |
| 3687 | "rustls 0.23.40", | 3911 | "rustls 0.23.40", |
| 3688 | "rustls-pki-types", | 3912 | "rustls-pki-types", |
| 3689 | "slab", | 3913 | "slab", |
| @@ -3968,7 +4192,7 @@ version = "0.12.28" | |||
| 3968 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4192 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3969 | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | 4193 | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" |
| 3970 | dependencies = [ | 4194 | dependencies = [ |
| 3971 | "base64", | 4195 | "base64 0.22.1", |
| 3972 | "bytes", | 4196 | "bytes", |
| 3973 | "encoding_rs", | 4197 | "encoding_rs", |
| 3974 | "futures-core", | 4198 | "futures-core", |
| @@ -4065,6 +4289,12 @@ dependencies = [ | |||
| 4065 | 4289 | ||
| 4066 | [[package]] | 4290 | [[package]] |
| 4067 | name = "rustc-hash" | 4291 | name = "rustc-hash" |
| 4292 | version = "1.1.0" | ||
| 4293 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4294 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | ||
| 4295 | |||
| 4296 | [[package]] | ||
| 4297 | name = "rustc-hash" | ||
| 4068 | version = "2.1.2" | 4298 | version = "2.1.2" |
| 4069 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4299 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4070 | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" | 4300 | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" |
| @@ -4094,6 +4324,19 @@ dependencies = [ | |||
| 4094 | 4324 | ||
| 4095 | [[package]] | 4325 | [[package]] |
| 4096 | name = "rustix" | 4326 | name = "rustix" |
| 4327 | version = "0.38.44" | ||
| 4328 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4329 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | ||
| 4330 | dependencies = [ | ||
| 4331 | "bitflags 2.13.0", | ||
| 4332 | "errno", | ||
| 4333 | "libc", | ||
| 4334 | "linux-raw-sys 0.4.15", | ||
| 4335 | "windows-sys 0.52.0", | ||
| 4336 | ] | ||
| 4337 | |||
| 4338 | [[package]] | ||
| 4339 | name = "rustix" | ||
| 4097 | version = "1.1.4" | 4340 | version = "1.1.4" |
| 4098 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4341 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4099 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | 4342 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" |
| @@ -4101,7 +4344,7 @@ dependencies = [ | |||
| 4101 | "bitflags 2.13.0", | 4344 | "bitflags 2.13.0", |
| 4102 | "errno", | 4345 | "errno", |
| 4103 | "libc", | 4346 | "libc", |
| 4104 | "linux-raw-sys", | 4347 | "linux-raw-sys 0.12.1", |
| 4105 | "windows-sys 0.61.2", | 4348 | "windows-sys 0.61.2", |
| 4106 | ] | 4349 | ] |
| 4107 | 4350 | ||
| @@ -4320,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4320 | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | 4563 | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" |
| 4321 | dependencies = [ | 4564 | dependencies = [ |
| 4322 | "base16ct", | 4565 | "base16ct", |
| 4323 | "der", | 4566 | "der 0.7.10", |
| 4324 | "generic-array", | 4567 | "generic-array", |
| 4325 | "pkcs8", | 4568 | "pkcs8", |
| 4326 | "subtle", | 4569 | "subtle", |
| @@ -4374,7 +4617,7 @@ dependencies = [ | |||
| 4374 | "phf 0.13.1", | 4617 | "phf 0.13.1", |
| 4375 | "phf_codegen", | 4618 | "phf_codegen", |
| 4376 | "precomputed-hash", | 4619 | "precomputed-hash", |
| 4377 | "rustc-hash", | 4620 | "rustc-hash 2.1.2", |
| 4378 | "servo_arc", | 4621 | "servo_arc", |
| 4379 | "smallvec", | 4622 | "smallvec", |
| 4380 | ] | 4623 | ] |
| @@ -4395,10 +4638,12 @@ version = "0.4.1" | |||
| 4395 | dependencies = [ | 4638 | dependencies = [ |
| 4396 | "chrono", | 4639 | "chrono", |
| 4397 | "cron", | 4640 | "cron", |
| 4641 | "dashmap 6.2.1", | ||
| 4398 | "dotenvy", | 4642 | "dotenvy", |
| 4399 | "futures-util", | 4643 | "futures-util", |
| 4400 | "glob", | 4644 | "glob", |
| 4401 | "image", | 4645 | "image", |
| 4646 | "piper-rs", | ||
| 4402 | "poise", | 4647 | "poise", |
| 4403 | "qrcode", | 4648 | "qrcode", |
| 4404 | "rand 0.10.1", | 4649 | "rand 0.10.1", |
| @@ -4544,7 +4789,7 @@ checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" | |||
| 4544 | dependencies = [ | 4789 | dependencies = [ |
| 4545 | "arrayvec", | 4790 | "arrayvec", |
| 4546 | "async-trait", | 4791 | "async-trait", |
| 4547 | "base64", | 4792 | "base64 0.22.1", |
| 4548 | "bitflags 2.13.0", | 4793 | "bitflags 2.13.0", |
| 4549 | "bytes", | 4794 | "bytes", |
| 4550 | "chrono", | 4795 | "chrono", |
| @@ -4557,7 +4802,7 @@ dependencies = [ | |||
| 4557 | "parking_lot 0.12.5", | 4802 | "parking_lot 0.12.5", |
| 4558 | "percent-encoding", | 4803 | "percent-encoding", |
| 4559 | "reqwest", | 4804 | "reqwest", |
| 4560 | "rustc-hash", | 4805 | "rustc-hash 2.1.2", |
| 4561 | "secrecy", | 4806 | "secrecy", |
| 4562 | "serde", | 4807 | "serde", |
| 4563 | "serde_cow", | 4808 | "serde_cow", |
| @@ -4668,6 +4913,12 @@ dependencies = [ | |||
| 4668 | 4913 | ||
| 4669 | [[package]] | 4914 | [[package]] |
| 4670 | name = "shlex" | 4915 | name = "shlex" |
| 4916 | version = "1.3.0" | ||
| 4917 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4918 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" | ||
| 4919 | |||
| 4920 | [[package]] | ||
| 4921 | name = "shlex" | ||
| 4671 | version = "2.0.1" | 4922 | version = "2.0.1" |
| 4672 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4923 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4673 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | 4924 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| @@ -4795,6 +5046,17 @@ dependencies = [ | |||
| 4795 | ] | 5046 | ] |
| 4796 | 5047 | ||
| 4797 | [[package]] | 5048 | [[package]] |
| 5049 | name = "socks" | ||
| 5050 | version = "0.3.4" | ||
| 5051 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 5052 | checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" | ||
| 5053 | dependencies = [ | ||
| 5054 | "byteorder", | ||
| 5055 | "libc", | ||
| 5056 | "winapi", | ||
| 5057 | ] | ||
| 5058 | |||
| 5059 | [[package]] | ||
| 4798 | name = "songbird" | 5060 | name = "songbird" |
| 4799 | version = "0.6.0" | 5061 | version = "0.6.0" |
| 4800 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5062 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4863,7 +5125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4863 | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" | 5125 | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" |
| 4864 | dependencies = [ | 5126 | dependencies = [ |
| 4865 | "base64ct", | 5127 | "base64ct", |
| 4866 | "der", | 5128 | "der 0.7.10", |
| 4867 | ] | 5129 | ] |
| 4868 | 5130 | ||
| 4869 | [[package]] | 5131 | [[package]] |
| @@ -4885,7 +5147,7 @@ version = "0.9.0" | |||
| 4885 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5147 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4886 | checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" | 5148 | checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" |
| 4887 | dependencies = [ | 5149 | dependencies = [ |
| 4888 | "base64", | 5150 | "base64 0.22.1", |
| 4889 | "bytes", | 5151 | "bytes", |
| 4890 | "cfg-if", | 5152 | "cfg-if", |
| 4891 | "chrono", | 5153 | "chrono", |
| @@ -4987,7 +5249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4987 | checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" | 5249 | checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" |
| 4988 | dependencies = [ | 5250 | dependencies = [ |
| 4989 | "atoi", | 5251 | "atoi", |
| 4990 | "base64", | 5252 | "base64 0.22.1", |
| 4991 | "bitflags 2.13.0", | 5253 | "bitflags 2.13.0", |
| 4992 | "byteorder", | 5254 | "byteorder", |
| 4993 | "chrono", | 5255 | "chrono", |
| @@ -5546,7 +5808,7 @@ dependencies = [ | |||
| 5546 | "fastrand", | 5808 | "fastrand", |
| 5547 | "getrandom 0.4.3", | 5809 | "getrandom 0.4.3", |
| 5548 | "once_cell", | 5810 | "once_cell", |
| 5549 | "rustix", | 5811 | "rustix 1.1.4", |
| 5550 | "windows-sys 0.61.2", | 5812 | "windows-sys 0.61.2", |
| 5551 | ] | 5813 | ] |
| 5552 | 5814 | ||
| @@ -5820,7 +6082,7 @@ version = "0.11.4" | |||
| 5820 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6082 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5821 | checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" | 6083 | checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" |
| 5822 | dependencies = [ | 6084 | dependencies = [ |
| 5823 | "base64", | 6085 | "base64 0.22.1", |
| 5824 | "bytes", | 6086 | "bytes", |
| 5825 | "fastrand", | 6087 | "fastrand", |
| 5826 | "futures-core", | 6088 | "futures-core", |
| @@ -6208,6 +6470,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 6208 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | 6470 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
| 6209 | 6471 | ||
| 6210 | [[package]] | 6472 | [[package]] |
| 6473 | name = "ureq" | ||
| 6474 | version = "3.4.0" | ||
| 6475 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6476 | checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" | ||
| 6477 | dependencies = [ | ||
| 6478 | "base64 0.23.1", | ||
| 6479 | "der 0.8.2", | ||
| 6480 | "log", | ||
| 6481 | "native-tls", | ||
| 6482 | "percent-encoding", | ||
| 6483 | "rustls-pki-types", | ||
| 6484 | "socks", | ||
| 6485 | "ureq-proto", | ||
| 6486 | "utf8-zero", | ||
| 6487 | "webpki-root-certs", | ||
| 6488 | ] | ||
| 6489 | |||
| 6490 | [[package]] | ||
| 6491 | name = "ureq-proto" | ||
| 6492 | version = "0.6.1" | ||
| 6493 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6494 | checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" | ||
| 6495 | dependencies = [ | ||
| 6496 | "base64 0.23.1", | ||
| 6497 | "http", | ||
| 6498 | "httparse", | ||
| 6499 | "log", | ||
| 6500 | ] | ||
| 6501 | |||
| 6502 | [[package]] | ||
| 6211 | name = "uriparse" | 6503 | name = "uriparse" |
| 6212 | version = "0.6.4" | 6504 | version = "0.6.4" |
| 6213 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6505 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -6243,6 +6535,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 6243 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" | 6535 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" |
| 6244 | 6536 | ||
| 6245 | [[package]] | 6537 | [[package]] |
| 6538 | name = "utf8-zero" | ||
| 6539 | version = "0.8.1" | ||
| 6540 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6541 | checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" | ||
| 6542 | |||
| 6543 | [[package]] | ||
| 6246 | name = "utf8_iter" | 6544 | name = "utf8_iter" |
| 6247 | version = "1.0.4" | 6545 | version = "1.0.4" |
| 6248 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6546 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -6435,6 +6733,15 @@ dependencies = [ | |||
| 6435 | ] | 6733 | ] |
| 6436 | 6734 | ||
| 6437 | [[package]] | 6735 | [[package]] |
| 6736 | name = "webpki-root-certs" | ||
| 6737 | version = "1.0.9" | ||
| 6738 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6739 | checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" | ||
| 6740 | dependencies = [ | ||
| 6741 | "rustls-pki-types", | ||
| 6742 | ] | ||
| 6743 | |||
| 6744 | [[package]] | ||
| 6438 | name = "webpki-roots" | 6745 | name = "webpki-roots" |
| 6439 | version = "0.26.11" | 6746 | version = "0.26.11" |
| 6440 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6747 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -6459,6 +6766,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 6459 | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | 6766 | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" |
| 6460 | 6767 | ||
| 6461 | [[package]] | 6768 | [[package]] |
| 6769 | name = "which" | ||
| 6770 | version = "4.4.2" | ||
| 6771 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 6772 | checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" | ||
| 6773 | dependencies = [ | ||
| 6774 | "either", | ||
| 6775 | "home", | ||
| 6776 | "once_cell", | ||
| 6777 | "rustix 0.38.44", | ||
| 6778 | ] | ||
| 6779 | |||
| 6780 | [[package]] | ||
| 6462 | name = "whoami" | 6781 | name = "whoami" |
| 6463 | version = "2.1.2" | 6782 | version = "2.1.2" |
| 6464 | source = "registry+https://github.com/rust-lang/crates.io-index" | 6783 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -76,3 +76,5 @@ uuid = { version = "1.23.3", features = ["v4"] } | |||
| 76 | glob = "0.3.3" | 76 | glob = "0.3.3" |
| 77 | tempfile = "3.27" | 77 | tempfile = "3.27" |
| 78 | futures-util = "0.3" | 78 | futures-util = "0.3" |
| 79 | piper-rs = "0.2.0" | ||
| 80 | dashmap = "6.2.1" | ||
diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d152090 --- /dev/null +++ b/build.rs | |||
| @@ -0,0 +1,52 @@ | |||
| 1 | fn main() { | ||
| 2 | // espeak-rs-sys builds espeak-ng from source but misses linking sonic | ||
| 3 | // and the speechPlayer audio_object symbols. piper-rs only uses | ||
| 4 | // espeak-ng for phonemization (never audio output), so stub implementations | ||
| 5 | // satisfy the linker without providing real functionality. | ||
| 6 | |||
| 7 | let out_dir = std::env::var("OUT_DIR").unwrap(); | ||
| 8 | let stubs_c = format!("{}/tts_stubs.c", out_dir); | ||
| 9 | let stubs_o = format!("{}/tts_stubs.o", out_dir); | ||
| 10 | let stubs_a = format!("{}/libtts_stubs.a", out_dir); | ||
| 11 | |||
| 12 | std::fs::write( | ||
| 13 | &stubs_c, | ||
| 14 | r#" | ||
| 15 | #include <stddef.h> | ||
| 16 | typedef void* sonicStream; | ||
| 17 | sonicStream sonicCreateStream(int sampleRate, int channels) { return NULL; } | ||
| 18 | void sonicDestroyStream(sonicStream s) {} | ||
| 19 | void sonicSetSpeed(sonicStream s, float speed) {} | ||
| 20 | float sonicGetSpeed(sonicStream s) { return 1.0f; } | ||
| 21 | void sonicWriteShortToStream(sonicStream s, const short *samples, int n) {} | ||
| 22 | int sonicReadShortFromStream(sonicStream s, short *samples, int n) { return 0; } | ||
| 23 | void sonicFlushStream(sonicStream s) {} | ||
| 24 | typedef void* audio_object_t; | ||
| 25 | int audio_object_open(audio_object_t o) { return 0; } | ||
| 26 | int audio_object_write(audio_object_t o, const void *buf, size_t n) { return (int)n; } | ||
| 27 | int audio_object_close(audio_object_t o) { return 0; } | ||
| 28 | int audio_object_flush(audio_object_t o) { return 0; } | ||
| 29 | int audio_object_drain(audio_object_t o) { return 0; } | ||
| 30 | const char* audio_object_strerror(audio_object_t o, int err) { return "stub"; } | ||
| 31 | audio_object_t create_audio_device_object(void* d, const char* device) { return NULL; } | ||
| 32 | "#, | ||
| 33 | ) | ||
| 34 | .expect("write stubs"); | ||
| 35 | |||
| 36 | // cc -c | ||
| 37 | let status = std::process::Command::new("cc") | ||
| 38 | .args(["-c", &stubs_c, "-o", &stubs_o]) | ||
| 39 | .status() | ||
| 40 | .expect("run cc"); | ||
| 41 | assert!(status.success(), "cc -c failed"); | ||
| 42 | |||
| 43 | // ar rcs | ||
| 44 | let status = std::process::Command::new("ar") | ||
| 45 | .args(["rcs", &stubs_a, &stubs_o]) | ||
| 46 | .status() | ||
| 47 | .expect("run ar"); | ||
| 48 | assert!(status.success(), "ar rcs failed"); | ||
| 49 | |||
| 50 | println!("cargo:rustc-link-search=native={}", out_dir); | ||
| 51 | println!("cargo:rustc-link-lib=static=tts_stubs"); | ||
| 52 | } | ||
diff --git a/src/commands/ai.rs b/src/commands/ai.rs index 61fb471..5dd98cb 100644 --- a/src/commands/ai.rs +++ b/src/commands/ai.rs | |||
| @@ -30,15 +30,14 @@ pub async fn senator( | |||
| 30 | }) | 30 | }) |
| 31 | .ok_or_else(lang::err_not_in_voice)?; | 31 | .ok_or_else(lang::err_not_in_voice)?; |
| 32 | 32 | ||
| 33 | let (audio_data, _) = | 33 | let audio_data = ctx.data().tts.synthesize(&response).await?; |
| 34 | crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &response).await?; | ||
| 35 | 34 | ||
| 36 | let call = ctx | 35 | let call = ctx |
| 37 | .data() | 36 | .data() |
| 38 | .voice_manager | 37 | .voice_manager |
| 39 | .connect(guild_id, channel_id) | 38 | .connect(guild_id, channel_id) |
| 40 | .await?; | 39 | .await?; |
| 41 | let input = crate::voice::decode_audio(&audio_data)?; | 40 | let input = crate::tts::pcm_bytes_to_input(audio_data); |
| 42 | let mut call_lock = call.lock().await; | 41 | let mut call_lock = call.lock().await; |
| 43 | if call_lock.current_channel().is_some() { | 42 | if call_lock.current_channel().is_some() { |
| 44 | call_lock.play_input(input); | 43 | call_lock.play_input(input); |
diff --git a/src/commands/tts.rs b/src/commands/tts.rs index 8d20179..f45d2e8 100644 --- a/src/commands/tts.rs +++ b/src/commands/tts.rs | |||
| @@ -15,7 +15,7 @@ pub enum OutputMode { | |||
| 15 | Vc, | 15 | Vc, |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | /// Generate TTS (Piper locally, Google as fallback) and output to file or voice channel | 18 | /// Generate TTS (local Piper) and output to file or voice channel |
| 19 | #[poise::command(slash_command)] | 19 | #[poise::command(slash_command)] |
| 20 | pub async fn tts( | 20 | pub async fn tts( |
| 21 | ctx: Context<'_>, | 21 | ctx: Context<'_>, |
| @@ -25,21 +25,21 @@ pub async fn tts( | |||
| 25 | ctx.defer().await?; | 25 | ctx.defer().await?; |
| 26 | 26 | ||
| 27 | let output = output.unwrap_or(OutputMode::Vc); | 27 | let output = output.unwrap_or(OutputMode::Vc); |
| 28 | let (audio_data, ext) = | 28 | let audio_data = ctx.data().tts.synthesize(&text).await?; |
| 29 | crate::tts::synthesize(&ctx.data().config, &ctx.data().http_client, &text).await?; | ||
| 30 | 29 | ||
| 31 | match output { | 30 | match output { |
| 32 | OutputMode::File => { | 31 | OutputMode::File => { |
| 32 | let wav = crate::tts::encode_wav(&audio_data, 48000, 2); | ||
| 33 | |||
| 33 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) | 34 | tokio::fs::create_dir_all(crate::config::TEMP_PATH) |
| 34 | .await | 35 | .await |
| 35 | .ok(); | 36 | .ok(); |
| 36 | let path = format!( | 37 | let path = format!( |
| 37 | "{}/tts_{}.{}", | 38 | "{}/tts_{}.wav", |
| 38 | crate::config::TEMP_PATH, | 39 | crate::config::TEMP_PATH, |
| 39 | uuid::Uuid::new_v4(), | 40 | uuid::Uuid::new_v4(), |
| 40 | ext | ||
| 41 | ); | 41 | ); |
| 42 | tokio::fs::write(&path, &audio_data).await?; | 42 | tokio::fs::write(&path, &wav).await?; |
| 43 | if let Err(e) = ctx | 43 | if let Err(e) = ctx |
| 44 | .send( | 44 | .send( |
| 45 | poise::CreateReply::default() | 45 | poise::CreateReply::default() |
| @@ -69,7 +69,7 @@ pub async fn tts( | |||
| 69 | .voice_manager | 69 | .voice_manager |
| 70 | .connect(guild_id, channel_id) | 70 | .connect(guild_id, channel_id) |
| 71 | .await?; | 71 | .await?; |
| 72 | let input = crate::voice::decode_audio(&audio_data)?; | 72 | let input = crate::tts::pcm_bytes_to_input(audio_data); |
| 73 | let mut call_lock = call.lock().await; | 73 | let mut call_lock = call.lock().await; |
| 74 | if call_lock.current_channel().is_some() { | 74 | if call_lock.current_channel().is_some() { |
| 75 | call_lock.play_input(input); | 75 | call_lock.play_input(input); |
diff --git a/src/events/mod.rs b/src/events/mod.rs index e4f1b22..87333c8 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs | |||
| @@ -28,15 +28,14 @@ fn format_duration(seconds: f64) -> String { | |||
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | async fn play_tts_in_channel( | 30 | async fn play_tts_in_channel( |
| 31 | config: crate::config::Config, | 31 | tts: std::sync::Arc<crate::tts::TtsState>, |
| 32 | http_client: reqwest::Client, | ||
| 33 | voice_manager: crate::voice::VoiceManager, | 32 | voice_manager: crate::voice::VoiceManager, |
| 34 | guild_id: serenity::GuildId, | 33 | guild_id: serenity::GuildId, |
| 35 | channel_id: serenity::ChannelId, | 34 | channel_id: serenity::ChannelId, |
| 36 | text: String, | 35 | text: String, |
| 37 | ) { | 36 | ) { |
| 38 | let audio_data = match crate::tts::synthesize(&config, &http_client, &text).await { | 37 | let audio_data = match tts.synthesize(&text).await { |
| 39 | Ok((bytes, _)) => bytes, | 38 | Ok(bytes) => bytes, |
| 40 | Err(e) => { | 39 | Err(e) => { |
| 41 | warn!("Failed to synthesize TTS: {e}"); | 40 | warn!("Failed to synthesize TTS: {e}"); |
| 42 | return; | 41 | return; |
| @@ -51,13 +50,7 @@ async fn play_tts_in_channel( | |||
| 51 | } | 50 | } |
| 52 | }; | 51 | }; |
| 53 | 52 | ||
| 54 | let input = match crate::voice::decode_audio(&audio_data) { | 53 | let input = crate::tts::pcm_bytes_to_input(audio_data); |
| 55 | Ok(i) => i, | ||
| 56 | Err(e) => { | ||
| 57 | warn!("Failed to decode TTS audio: {e}"); | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | }; | ||
| 61 | 54 | ||
| 62 | let mut call_lock = call.lock().await; | 55 | let mut call_lock = call.lock().await; |
| 63 | if call_lock.current_channel().is_some() { | 56 | if call_lock.current_channel().is_some() { |
| @@ -97,6 +90,16 @@ pub async fn handle_voice_state_update( | |||
| 97 | .map(|m| m.display_name().to_string()) | 90 | .map(|m| m.display_name().to_string()) |
| 98 | .unwrap_or_default(); | 91 | .unwrap_or_default(); |
| 99 | 92 | ||
| 93 | // Pre-generate all 6 event phrases for this user (background, non-blocking). | ||
| 94 | // First event per user pays ~120ms; subsequent events are instant from cache. | ||
| 95 | { | ||
| 96 | let tts = data.tts.clone(); | ||
| 97 | let name = display_name.clone(); | ||
| 98 | tokio::spawn(async move { | ||
| 99 | tts.pre_generate(&name).await; | ||
| 100 | }); | ||
| 101 | } | ||
| 102 | |||
| 100 | let now = current_ts as f64; | 103 | let now = current_ts as f64; |
| 101 | 104 | ||
| 102 | let user_id_str = user_id.to_string(); | 105 | let user_id_str = user_id.to_string(); |
| @@ -293,8 +296,7 @@ pub async fn handle_voice_state_update( | |||
| 293 | && let Some(cid) = tts_channel.or(new_channel) | 296 | && let Some(cid) = tts_channel.or(new_channel) |
| 294 | { | 297 | { |
| 295 | tokio::spawn(play_tts_in_channel( | 298 | tokio::spawn(play_tts_in_channel( |
| 296 | data.config.clone(), | 299 | data.tts.clone(), |
| 297 | data.http_client.clone(), | ||
| 298 | data.voice_manager.clone(), | 300 | data.voice_manager.clone(), |
| 299 | gid, | 301 | gid, |
| 300 | cid, | 302 | cid, |
| @@ -314,8 +316,7 @@ pub async fn handle_voice_state_update( | |||
| 314 | && let Some(cid) = tts_channel.or(new_channel) | 316 | && let Some(cid) = tts_channel.or(new_channel) |
| 315 | { | 317 | { |
| 316 | tokio::spawn(play_tts_in_channel( | 318 | tokio::spawn(play_tts_in_channel( |
| 317 | data.config.clone(), | 319 | data.tts.clone(), |
| 318 | data.http_client.clone(), | ||
| 319 | data.voice_manager.clone(), | 320 | data.voice_manager.clone(), |
| 320 | gid, | 321 | gid, |
| 321 | cid, | 322 | cid, |
| @@ -330,8 +331,7 @@ pub async fn handle_voice_state_update( | |||
| 330 | && let Some(gid) = guild_id | 331 | && let Some(gid) = guild_id |
| 331 | { | 332 | { |
| 332 | tokio::spawn(play_tts_in_channel( | 333 | tokio::spawn(play_tts_in_channel( |
| 333 | data.config.clone(), | 334 | data.tts.clone(), |
| 334 | data.http_client.clone(), | ||
| 335 | data.voice_manager.clone(), | 335 | data.voice_manager.clone(), |
| 336 | gid, | 336 | gid, |
| 337 | cid, | 337 | cid, |
diff --git a/src/lang.rs b/src/lang.rs index de0c7d7..53888eb 100644 --- a/src/lang.rs +++ b/src/lang.rs | |||
| @@ -114,13 +114,6 @@ pub fn fmt_voice_join_failed(e: &dyn std::fmt::Display) -> String { | |||
| 114 | pub const L_TTS_SAVED: &str = "TTS zapisany"; | 114 | pub const L_TTS_SAVED: &str = "TTS zapisany"; |
| 115 | pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; | 115 | pub const L_TTS_PLAYING: &str = "Grom TTS na kanale głosowym"; |
| 116 | 116 | ||
| 117 | pub fn fmt_tts_piper_launch(e: &str) -> String { | ||
| 118 | format!("Piper sie nie uruchomił w czas: `{e}`") | ||
| 119 | } | ||
| 120 | pub fn fmt_tts_piper_failed(e: &str) -> String { | ||
| 121 | format!("Piper TTS sie nie powiodło: `{e}`") | ||
| 122 | } | ||
| 123 | |||
| 124 | // ── Trigger command ─────────────────────────────────────────────────── | 117 | // ── Trigger command ─────────────────────────────────────────────────── |
| 125 | 118 | ||
| 126 | pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; | 119 | pub const L_TRIGGER_UPDATED: &str = "Trigger zaktualizowany"; |
diff --git a/src/main.rs b/src/main.rs index 7f57421..5ab33ab 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -50,6 +50,8 @@ pub struct Data { | |||
| 50 | pub ac_search_cache: AcSearchCache, | 50 | pub ac_search_cache: AcSearchCache, |
| 51 | /// Active music track per guild (only one music source at a time). | 51 | /// Active music track per guild (only one music source at a time). |
| 52 | pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>, | 52 | pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>, |
| 53 | /// TTS engine: piper model + audio cache. | ||
| 54 | pub tts: Arc<tts::TtsState>, | ||
| 53 | } | 55 | } |
| 54 | 56 | ||
| 55 | impl Data { | 57 | impl Data { |
| @@ -187,7 +189,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | |||
| 187 | 189 | ||
| 188 | let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); | 190 | let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); |
| 189 | 191 | ||
| 192 | let tts = Arc::new(tts::TtsState::new(config.piper_model_path())); | ||
| 193 | |||
| 190 | let data = Data { | 194 | let data = Data { |
| 195 | tts, | ||
| 191 | config, | 196 | config, |
| 192 | db_pool, | 197 | db_pool, |
| 193 | voice_manager: vm, | 198 | voice_manager: vm, |
| @@ -1,147 +1,291 @@ | |||
| 1 | use std::io::Cursor; | ||
| 1 | use std::path::PathBuf; | 2 | use std::path::PathBuf; |
| 2 | use std::process::Stdio; | 3 | use std::sync::OnceLock; |
| 3 | use std::time::Instant; | 4 | use std::time::{Duration, Instant}; |
| 4 | 5 | ||
| 5 | use tokio::io::AsyncWriteExt; | 6 | use dashmap::DashMap; |
| 7 | use piper_rs::Piper; | ||
| 8 | use tokio::sync::Mutex; | ||
| 6 | use tracing::{debug, info, warn}; | 9 | use tracing::{debug, info, warn}; |
| 7 | 10 | ||
| 8 | use crate::config; | ||
| 9 | use crate::config::TEMP_PATH; | ||
| 10 | use crate::error::{Error, Result}; | 11 | use crate::error::{Error, Result}; |
| 11 | 12 | ||
| 12 | /// Synthesize speech for `text`. | 13 | // ── Constants ────────────────────────────────────────────────────────── |
| 13 | /// | 14 | |
| 14 | /// Primary engine is local Piper TTS (via the `piper` CLI). If Piper is not | 15 | /// Target sample rate for songbird playback. |
| 15 | /// installed, fails, or `TTS_MODEL_PATH` is unset, we fall back to Google | 16 | const TARGET_SAMPLE_RATE: u32 = 48_000; |
| 16 | /// Translate TTS over HTTP. | 17 | |
| 18 | /// Cache entries older than this are evicted on access. | ||
| 19 | const CACHE_TTL: Duration = Duration::from_secs(3600); | ||
| 20 | |||
| 21 | /// All voice-state TTS event types. | ||
| 22 | const EVENT_TYPES: &[&str] = &[ | ||
| 23 | "joined", | ||
| 24 | "left", | ||
| 25 | "deafened", | ||
| 26 | "undeafened", | ||
| 27 | "muted", | ||
| 28 | "unmuted", | ||
| 29 | ]; | ||
| 30 | |||
| 31 | // ── TtsState ─────────────────────────────────────────────────────────── | ||
| 32 | |||
| 33 | /// Shared TTS state: piper model instance + audio cache. | ||
| 17 | /// | 34 | /// |
| 18 | /// Returns the encoded audio bytes plus the file extension to use when saving | 35 | /// Held in poise `Data`. Piper is loaded lazily on first synthesis call. |
| 19 | /// (`"wav"` for Piper, `"mp3"` for Google). | 36 | /// Cache maps full text string → resampled 48 kHz stereo PCM bytes. |
| 20 | pub async fn synthesize( | 37 | pub struct TtsState { |
| 21 | config: &config::Config, | 38 | piper: OnceLock<Mutex<Piper>>, |
| 22 | http_client: &reqwest::Client, | 39 | cache: DashMap<String, (Vec<u8>, Instant)>, |
| 23 | text: &str, | 40 | model_path: Option<PathBuf>, |
| 24 | ) -> Result<(Vec<u8>, &'static str)> { | 41 | } |
| 25 | match config.piper_model_path() { | 42 | |
| 26 | Some(model_path) => { | 43 | impl TtsState { |
| 27 | let started = Instant::now(); | 44 | pub fn new(model_path: Option<PathBuf>) -> Self { |
| 28 | match piper_synthesize(&model_path, text).await { | 45 | Self { |
| 29 | Ok(bytes) => { | 46 | piper: OnceLock::new(), |
| 30 | info!( | 47 | cache: DashMap::new(), |
| 31 | model = %model_path.display(), | 48 | model_path, |
| 32 | text_len = text.len(), | ||
| 33 | bytes = bytes.len(), | ||
| 34 | elapsed_ms = started.elapsed().as_millis() as u64, | ||
| 35 | "Piper TTS synthesized" | ||
| 36 | ); | ||
| 37 | return Ok((bytes, "wav")); | ||
| 38 | } | ||
| 39 | Err(e) => { | ||
| 40 | warn!( | ||
| 41 | model = %model_path.display(), | ||
| 42 | text_len = text.len(), | ||
| 43 | elapsed_ms = started.elapsed().as_millis() as u64, | ||
| 44 | "Piper TTS failed, falling back to Google TTS: {e}" | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | 49 | } |
| 49 | None => { | 50 | } |
| 50 | debug!("TTS_MODEL_PATH is not set; using Google TTS fallback"); | 51 | |
| 52 | /// Get or initialize the Piper model. Returns error if model path is | ||
| 53 | /// unset or model fails to load. | ||
| 54 | async fn get_piper(&self) -> Result<&Mutex<Piper>> { | ||
| 55 | if let Some(piper) = self.piper.get() { | ||
| 56 | return Ok(piper); | ||
| 51 | } | 57 | } |
| 58 | |||
| 59 | let path = self | ||
| 60 | .model_path | ||
| 61 | .as_ref() | ||
| 62 | .ok_or_else(|| Error::Custom("TTS_MODEL_PATH is not set".into()))?; | ||
| 63 | |||
| 64 | // Resolve .onnx.json config path from model path | ||
| 65 | let onnx_path = path.clone(); | ||
| 66 | let config_path = { | ||
| 67 | let s = path.to_string_lossy(); | ||
| 68 | if s.ends_with(".onnx") { | ||
| 69 | PathBuf::from(format!("{}.json", s)) | ||
| 70 | } else { | ||
| 71 | path.clone() | ||
| 72 | } | ||
| 73 | }; | ||
| 74 | |||
| 75 | debug!( | ||
| 76 | onnx = %onnx_path.display(), | ||
| 77 | config = %config_path.display(), | ||
| 78 | "loading piper model" | ||
| 79 | ); | ||
| 80 | let piper = Piper::new(&onnx_path, &config_path).map_err(|e| { | ||
| 81 | Error::Custom(format!("Failed to load piper model: {e}")) | ||
| 82 | })?; | ||
| 83 | info!("piper model loaded successfully"); | ||
| 84 | |||
| 85 | // set() may fail if another task initialized first — that's fine | ||
| 86 | let _ = self.piper.set(Mutex::new(piper)); | ||
| 87 | Ok(self.piper.get().unwrap()) | ||
| 52 | } | 88 | } |
| 53 | 89 | ||
| 54 | let started = Instant::now(); | 90 | /// Look up cached audio for a text string. Returns `None` if uncached |
| 55 | let bytes = google_synthesize(http_client, text).await?; | 91 | /// or expired. |
| 56 | info!( | 92 | fn cache_lookup(&self, text: &str) -> Option<Vec<u8>> { |
| 57 | text_len = text.len(), | 93 | let entry = self.cache.get(text)?; |
| 58 | bytes = bytes.len(), | 94 | if entry.1.elapsed() > CACHE_TTL { |
| 59 | elapsed_ms = started.elapsed().as_millis() as u64, | 95 | drop(entry); |
| 60 | "Google TTS synthesized" | 96 | self.cache.remove(text); |
| 61 | ); | 97 | None |
| 62 | Ok((bytes, "mp3")) | 98 | } else { |
| 63 | } | 99 | Some(entry.0.clone()) |
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Store audio bytes in the cache. | ||
| 104 | fn cache_store(&self, text: String, audio: Vec<u8>) { | ||
| 105 | self.cache.insert(text, (audio, Instant::now())); | ||
| 106 | } | ||
| 64 | 107 | ||
| 65 | /// Run `piper` as a subprocess, feeding `text` on stdin and collecting the | 108 | /// Evict all expired cache entries. |
| 66 | /// generated WAV from `--output_file`. | 109 | fn cache_evict(&self) { |
| 67 | async fn piper_synthesize(model_path: &PathBuf, text: &str) -> Result<Vec<u8>> { | 110 | self.cache |
| 68 | tokio::fs::create_dir_all(TEMP_PATH).await.ok(); | 111 | .retain(|_, (_, inserted_at)| inserted_at.elapsed() <= CACHE_TTL); |
| 69 | |||
| 70 | let out_path = format!("{}/tts_piper_{}.wav", TEMP_PATH, uuid::Uuid::new_v4()); | ||
| 71 | let model = model_path.to_string_lossy().to_string(); | ||
| 72 | |||
| 73 | debug!(model = %model, "spawning piper subprocess"); | ||
| 74 | let started = Instant::now(); | ||
| 75 | |||
| 76 | let mut child = tokio::process::Command::new("piper") | ||
| 77 | .arg("--model") | ||
| 78 | .arg(&model) | ||
| 79 | .arg("--output_file") | ||
| 80 | .arg(&out_path) | ||
| 81 | .stdin(Stdio::piped()) | ||
| 82 | .stdout(Stdio::piped()) | ||
| 83 | .stderr(Stdio::piped()) | ||
| 84 | .spawn() | ||
| 85 | .map_err(|e| Error::Custom(crate::lang::fmt_tts_piper_launch(&e.to_string())))?; | ||
| 86 | |||
| 87 | { | ||
| 88 | let mut stdin = child | ||
| 89 | .stdin | ||
| 90 | .take() | ||
| 91 | .ok_or_else(|| Error::Custom("Piper stdin unavailable".into()))?; | ||
| 92 | stdin.write_all(text.as_bytes()).await?; | ||
| 93 | stdin.write_all(b"\n").await?; | ||
| 94 | } | 112 | } |
| 95 | 113 | ||
| 96 | let output = child.wait_with_output().await?; | 114 | /// Synthesize text → resampled 48 kHz stereo PCM bytes. |
| 97 | let elapsed_ms = started.elapsed().as_millis() as u64; | 115 | /// |
| 116 | /// Checks cache first. On miss, runs piper inference, resamples, caches, | ||
| 117 | /// and returns. | ||
| 118 | pub async fn synthesize(&self, text: &str) -> Result<Vec<u8>> { | ||
| 119 | // Cache hit | ||
| 120 | if let Some(cached) = self.cache_lookup(text) { | ||
| 121 | debug!(text_len = text.len(), "tts cache hit"); | ||
| 122 | return Ok(cached); | ||
| 123 | } | ||
| 124 | |||
| 125 | // Cache miss — synthesize | ||
| 126 | let piper = self.get_piper().await?; | ||
| 127 | let started = Instant::now(); | ||
| 98 | 128 | ||
| 99 | if !output.status.success() { | 129 | let (samples, sample_rate) = { |
| 100 | let stderr = String::from_utf8_lossy(&output.stderr); | 130 | let mut lock = piper.lock().await; |
| 101 | warn!( | 131 | lock.create(text, false, None, None, None, None) |
| 102 | model = %model, | 132 | .map_err(|e| Error::Custom(format!("piper synthesis failed: {e}")))? |
| 133 | }; | ||
| 134 | |||
| 135 | let elapsed_ms = started.elapsed().as_millis() as u64; | ||
| 136 | debug!( | ||
| 137 | text_len = text.len(), | ||
| 138 | samples = samples.len(), | ||
| 139 | sample_rate, | ||
| 103 | elapsed_ms, | 140 | elapsed_ms, |
| 104 | "piper exited with error: {}", | 141 | "piper synthesized" |
| 105 | stderr.trim_end() | ||
| 106 | ); | 142 | ); |
| 107 | let _ = tokio::fs::remove_file(&out_path).await; | 143 | |
| 108 | return Err(Error::Custom(crate::lang::fmt_tts_piper_failed( | 144 | // Resample mono → stereo at target sample rate |
| 109 | stderr.trim_end(), | 145 | let pcm_bytes = resample_to_stereo_bytes(&samples, sample_rate, TARGET_SAMPLE_RATE); |
| 110 | ))); | 146 | |
| 147 | self.cache_store(text.to_string(), pcm_bytes.clone()); | ||
| 148 | Ok(pcm_bytes) | ||
| 111 | } | 149 | } |
| 112 | 150 | ||
| 113 | let bytes = tokio::fs::read(&out_path).await?; | 151 | /// Pre-generate all 6 event phrases for a display name. |
| 114 | debug!( | 152 | /// |
| 115 | model = %model, | 153 | /// Called on first voice event for a user. Synthesizes all phrases and |
| 116 | bytes = bytes.len(), | 154 | /// caches them so subsequent events are instant. |
| 117 | elapsed_ms, | 155 | pub async fn pre_generate(&self, display_name: &str) { |
| 118 | "piper succeeded" | 156 | let phrases: Vec<String> = EVENT_TYPES |
| 119 | ); | 157 | .iter() |
| 120 | let _ = tokio::fs::remove_file(&out_path).await; | 158 | .map(|evt| make_event_phrase(display_name, evt)) |
| 121 | Ok(bytes) | 159 | .collect(); |
| 160 | |||
| 161 | for phrase in &phrases { | ||
| 162 | if self.cache_lookup(phrase).is_some() { | ||
| 163 | continue; | ||
| 164 | } | ||
| 165 | if let Err(e) = self.synthesize(phrase).await { | ||
| 166 | warn!( | ||
| 167 | display_name, | ||
| 168 | phrase, "pre-generation failed: {e}" | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | // Evict expired entries while we're at it | ||
| 174 | self.cache_evict(); | ||
| 175 | } | ||
| 122 | } | 176 | } |
| 123 | 177 | ||
| 124 | /// Fetch Google Translate TTS (Polish) over HTTP. | 178 | // ── Phrase construction ──────────────────────────────────────────────── |
| 125 | async fn google_synthesize(client: &reqwest::Client, text: &str) -> Result<Vec<u8>> { | 179 | |
| 126 | let tts_url = format!( | 180 | /// Build the TTS phrase for a voice event + display name. |
| 127 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | 181 | pub fn make_event_phrase(display_name: &str, event: &str) -> String { |
| 128 | urlencoding(text) | 182 | let suffix = match event { |
| 129 | ); | 183 | "joined" => "sie zespawnił", |
| 130 | debug!(url_len = tts_url.len(), "fetching Google TTS"); | 184 | "left" => "jest chujem", |
| 131 | let bytes = client.get(&tts_url).send().await?.bytes().await?; | 185 | "deafened" => "jest bity przez ojca", |
| 132 | Ok(bytes.to_vec()) | 186 | "undeafened" => "uciekł od ojca", |
| 187 | "muted" => "idzie robić jajecznice", | ||
| 188 | "unmuted" => "zrobił jajecznice", | ||
| 189 | _ => return format!("{display_name} {event}"), | ||
| 190 | }; | ||
| 191 | format!("{display_name} {suffix}") | ||
| 133 | } | 192 | } |
| 134 | 193 | ||
| 135 | pub fn urlencoding(s: &str) -> String { | 194 | // ── Resampling ───────────────────────────────────────────────────────── |
| 136 | let mut out = String::with_capacity(s.len()); | 195 | |
| 137 | for &byte in s.as_bytes() { | 196 | /// Resample mono f32 PCM from `src_rate` to `dst_rate`, duplicate to stereo, |
| 138 | match byte { | 197 | /// and return as little-endian bytes. |
| 139 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { | 198 | fn resample_to_stereo_bytes(samples: &[f32], src_rate: u32, dst_rate: u32) -> Vec<u8> { |
| 140 | out.push(byte as char) | 199 | if src_rate == dst_rate { |
| 141 | } | 200 | // Just duplicate mono → stereo, no resampling needed |
| 142 | b' ' => out.push('+'), | 201 | let mut out = Vec::with_capacity(samples.len() * 2 * 4); |
| 143 | _ => out.push_str(&format!("%{:02X}", byte)), | 202 | for &s in samples { |
| 203 | out.extend_from_slice(&s.to_le_bytes()); | ||
| 204 | out.extend_from_slice(&s.to_le_bytes()); | ||
| 144 | } | 205 | } |
| 206 | return out; | ||
| 145 | } | 207 | } |
| 208 | |||
| 209 | let ratio = src_rate as f64 / dst_rate as f64; | ||
| 210 | let out_len = (samples.len() as f64 / ratio).ceil() as usize; | ||
| 211 | let mut out = Vec::with_capacity(out_len * 2 * 4); | ||
| 212 | |||
| 213 | for i in 0..out_len { | ||
| 214 | let src_idx = i as f64 * ratio; | ||
| 215 | let idx = src_idx as usize; | ||
| 216 | let frac = src_idx - idx as f64; | ||
| 217 | |||
| 218 | let sample = if idx + 1 < samples.len() { | ||
| 219 | // Linear interpolation | ||
| 220 | samples[idx] + (samples[idx + 1] - samples[idx]) * frac as f32 | ||
| 221 | } else if idx < samples.len() { | ||
| 222 | samples[idx] | ||
| 223 | } else { | ||
| 224 | 0.0 | ||
| 225 | }; | ||
| 226 | |||
| 227 | // Mono → stereo duplication | ||
| 228 | out.extend_from_slice(&sample.to_le_bytes()); | ||
| 229 | out.extend_from_slice(&sample.to_le_bytes()); | ||
| 230 | } | ||
| 231 | |||
| 146 | out | 232 | out |
| 147 | } | 233 | } |
| 234 | |||
| 235 | // ── WAV encoding ─────────────────────────────────────────────────────── | ||
| 236 | |||
| 237 | /// Encode raw f32 stereo PCM bytes into a WAV file (16-bit PCM). | ||
| 238 | /// | ||
| 239 | /// `pcm_f32_bytes` is the raw little-endian f32 interleaved stereo data | ||
| 240 | /// (e.g. from `resample_to_stereo_bytes`). | ||
| 241 | pub fn encode_wav(pcm_f32_bytes: &[u8], sample_rate: u32, channels: u16) -> Vec<u8> { | ||
| 242 | let bits_per_sample: u16 = 16; | ||
| 243 | let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8; | ||
| 244 | let block_align = channels * bits_per_sample / 8; | ||
| 245 | |||
| 246 | // Convert f32 samples to i16 | ||
| 247 | let sample_count = pcm_f32_bytes.len() / 4; // 4 bytes per f32 | ||
| 248 | let mut pcm_i16 = Vec::with_capacity(sample_count * 2); | ||
| 249 | for chunk in pcm_f32_bytes.chunks_exact(4) { | ||
| 250 | let f = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); | ||
| 251 | let i = (f * 32767.0).clamp(-32768.0, 32767.0) as i16; | ||
| 252 | pcm_i16.extend_from_slice(&i.to_le_bytes()); | ||
| 253 | } | ||
| 254 | |||
| 255 | let data_size = pcm_i16.len() as u32; | ||
| 256 | let file_size = 36 + data_size; | ||
| 257 | |||
| 258 | let mut wav = Vec::with_capacity(44 + pcm_i16.len()); | ||
| 259 | |||
| 260 | // RIFF header | ||
| 261 | wav.extend_from_slice(b"RIFF"); | ||
| 262 | wav.extend_from_slice(&file_size.to_le_bytes()); | ||
| 263 | wav.extend_from_slice(b"WAVE"); | ||
| 264 | |||
| 265 | // fmt chunk | ||
| 266 | wav.extend_from_slice(b"fmt "); | ||
| 267 | wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size | ||
| 268 | wav.extend_from_slice(&1u16.to_le_bytes()); // PCM format | ||
| 269 | wav.extend_from_slice(&channels.to_le_bytes()); | ||
| 270 | wav.extend_from_slice(&sample_rate.to_le_bytes()); | ||
| 271 | wav.extend_from_slice(&byte_rate.to_le_bytes()); | ||
| 272 | wav.extend_from_slice(&block_align.to_le_bytes()); | ||
| 273 | wav.extend_from_slice(&bits_per_sample.to_le_bytes()); | ||
| 274 | |||
| 275 | // data chunk | ||
| 276 | wav.extend_from_slice(b"data"); | ||
| 277 | wav.extend_from_slice(&data_size.to_le_bytes()); | ||
| 278 | wav.extend_from_slice(&pcm_i16); | ||
| 279 | |||
| 280 | wav | ||
| 281 | } | ||
| 282 | |||
| 283 | // ── Songbird Input helper ────────────────────────────────────────────── | ||
| 284 | |||
| 285 | /// Create a songbird `Input` from resampled f32 stereo PCM bytes. | ||
| 286 | pub fn pcm_bytes_to_input(pcm_f32_bytes: Vec<u8>) -> songbird::input::Input { | ||
| 287 | use songbird::input::RawAdapter; | ||
| 288 | let cursor = Cursor::new(pcm_f32_bytes); | ||
| 289 | // 2 channels (stereo), 48 kHz — matches TARGET_SAMPLE_RATE | ||
| 290 | RawAdapter::new(cursor, TARGET_SAMPLE_RATE, 2).into() | ||
| 291 | } | ||
diff --git a/tests/tts_smoke.rs b/tests/tts_smoke.rs new file mode 100644 index 0000000..e98d56d --- /dev/null +++ b/tests/tts_smoke.rs | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | //! Smoke test: verify piper-rs loads the Polish model and synthesizes audio. | ||
| 2 | |||
| 3 | use std::path::Path; | ||
| 4 | |||
| 5 | #[test] | ||
| 6 | fn piper_load_and_synthesize() { | ||
| 7 | let model_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("piper"); | ||
| 8 | let onnx = model_dir.join("pl_PL-bass-high.onnx"); | ||
| 9 | let json = model_dir.join("pl_PL-bass-high.onnx.json"); | ||
| 10 | |||
| 11 | assert!(onnx.exists(), "model file missing: {}", onnx.display()); | ||
| 12 | assert!(json.exists(), "config file missing: {}", json.display()); | ||
| 13 | |||
| 14 | let mut piper = | ||
| 15 | piper_rs::Piper::new(&onnx, &json).expect("failed to load piper model"); | ||
| 16 | |||
| 17 | let text = "Kamil sie zespawnił"; | ||
| 18 | let (samples, sample_rate) = piper | ||
| 19 | .create(text, false, None, None, None, None) | ||
| 20 | .expect("synthesis failed"); | ||
| 21 | |||
| 22 | assert!(!samples.is_empty(), "no samples produced"); | ||
| 23 | assert!(sample_rate > 0, "invalid sample rate: {sample_rate}"); | ||
| 24 | assert!( | ||
| 25 | samples.len() as u32 > sample_rate / 10, | ||
| 26 | "suspiciously short output: {} samples at {} Hz", | ||
| 27 | samples.len(), | ||
| 28 | sample_rate | ||
| 29 | ); | ||
| 30 | |||
| 31 | let duration = samples.len() as f64 / sample_rate as f64; | ||
| 32 | println!( | ||
| 33 | "OK: '{}' → {} samples @ {} Hz ({:.2}s)", | ||
| 34 | text, | ||
| 35 | samples.len(), | ||
| 36 | sample_rate, | ||
| 37 | duration, | ||
| 38 | ); | ||
| 39 | |||
| 40 | // Second call measures cached-model synthesis speed | ||
| 41 | let start = std::time::Instant::now(); | ||
| 42 | let (samples2, sr2) = piper | ||
| 43 | .create("Kamil jest chujem", false, None, None, None, None) | ||
| 44 | .expect("second synthesis failed"); | ||
| 45 | let elapsed = start.elapsed(); | ||
| 46 | println!( | ||
| 47 | "Cached: '{}' → {} samples @ {} Hz in {:?}", | ||
| 48 | "Kamil jest chujem", | ||
| 49 | samples2.len(), | ||
| 50 | sr2, | ||
| 51 | elapsed, | ||
| 52 | ); | ||
| 53 | assert!(!samples2.is_empty()); | ||
| 54 | } | ||
