From 0b0fa8c04e821b1db4b7c2836e200e9e9f6618b0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 7 Mar 2026 17:57:32 +0100 Subject: [PATCH] refactor(voice): replace HTTP with stdin/stdout IPC for sr-voice workers Gemma 2 T&C compliance: exposed HTTP ports allow mods or external code to reach the model, complicating license enforcement. Switch to piped stdin/stdout (JSONL protocol) so the model is only reachable through the game server's internal queue. - worker.rs: VoicePipe owns Child + piped stdin/stdout, VoiceProcessConfig replaces port-based config, workers spawn their own sr-voice child - hardware.rs: remove VoiceInstanceManager (port/process lifecycle), replace with evaluate_scaling() free function + HardwareProbe::voice_config() - sr-voice: add --stdio flag to serve command, new stdio.rs JSONL mode - Remove ureq dependency from server crate (no longer needed) Co-Authored-By: Claude Opus 4.6 --- server/Cargo.lock | 542 +---------------------------------- server/Cargo.toml | 1 - server/sr-voice/src/main.rs | 25 +- server/sr-voice/src/stdio.rs | 69 +++++ server/src/voice/hardware.rs | 330 ++++++++------------- server/src/voice/mod.rs | 4 +- server/src/voice/worker.rs | 180 ++++++++---- 7 files changed, 334 insertions(+), 817 deletions(-) create mode 100644 server/sr-voice/src/stdio.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 5e011014a..9bcb90c59 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -64,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -140,12 +134,6 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bevy_app" version = "0.18.0" @@ -383,16 +371,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -470,15 +448,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "critical-section" version = "1.2.0" @@ -517,7 +486,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -567,17 +536,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "disqualified" version = "1.0.0" @@ -633,43 +591,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -723,17 +656,6 @@ dependencies = [ "slab", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -792,108 +714,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -947,12 +767,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "log" version = "0.4.29" @@ -974,16 +788,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "nix" version = "0.31.1" @@ -1017,7 +821,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1095,12 +899,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project" version = "1.1.10" @@ -1148,15 +946,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1216,7 +1005,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.4", + "getrandom", ] [[package]] @@ -1236,20 +1025,6 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rmp" version = "0.8.15" @@ -1275,7 +1050,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64 0.21.7", + "base64", "bitflags", "serde", "serde_derive", @@ -1296,41 +1071,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -1426,7 +1166,6 @@ dependencies = [ "thiserror", "tracing", "tracing-subscriber", - "ureq", ] [[package]] @@ -1438,18 +1177,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - [[package]] name = "slab" version = "0.4.12" @@ -1501,12 +1228,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.114" @@ -1518,17 +1239,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sysinfo" version = "0.35.2" @@ -1572,16 +1282,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1716,48 +1416,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1770,7 +1428,7 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ - "getrandom 0.3.4", + "getrandom", "js-sys", "serde_core", "wasm-bindgen", @@ -1799,12 +1457,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1883,24 +1535,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "wgpu-types" version = "27.0.1" @@ -2046,15 +1680,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2064,22 +1689,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -2089,54 +1698,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.14" @@ -2152,35 +1713,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.39" @@ -2201,66 +1733,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/server/Cargo.toml b/server/Cargo.toml index 154525dc6..e689d78a0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,7 +19,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } clap = { version = "4", features = ["derive"] } crossbeam-channel = "0.5" -ureq = { version = "2", features = ["json"] } sysinfo = "0.35" serde_json = "1" diff --git a/server/sr-voice/src/main.rs b/server/sr-voice/src/main.rs index 65ba223e8..223af3be1 100644 --- a/server/sr-voice/src/main.rs +++ b/server/sr-voice/src/main.rs @@ -1,6 +1,7 @@ mod inference; mod prompt; mod server; +mod stdio; use std::io::Read; use std::time::{Duration, Instant}; @@ -34,7 +35,7 @@ enum Command { /// Path to GGUF model file #[arg(long)] model: String, - /// Listen port + /// Listen port (ignored when --stdio is set) #[arg(long, default_value = "8321")] port: u16, /// CPU threads for inference @@ -43,6 +44,11 @@ enum Command { /// Context window size in tokens #[arg(long, default_value = "512")] ctx_size: u32, + /// Run in stdio mode: read JSONL from stdin, write JSONL to stdout. + /// No network port is opened. Used by the game server's worker pool + /// for Gemma 2 T&C compliance (no exposed inference endpoint). + #[arg(long)] + stdio: bool, }, /// Generate text from a single prompt (requires running server) Generate { @@ -83,7 +89,7 @@ fn main() -> Result<(), Box> { let cli = Cli::parse(); match cli.command { - Command::Serve { model, port, threads, ctx_size } => { + Command::Serve { model, port, threads, ctx_size, stdio } => { let threads = threads.unwrap_or_else(default_threads); let config = InferenceConfig { model_path: model.clone(), @@ -96,12 +102,17 @@ fn main() -> Result<(), Box> { let engine = InferenceEngine::load(&config)?; eprintln!("Model loaded ({} threads, {} ctx)", threads, ctx_size); - let model_name = std::path::Path::new(&model) - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or(model); + if stdio { + eprintln!("sr-voice stdio mode — reading JSONL from stdin"); + stdio::run_stdio(engine)?; + } else { + let model_name = std::path::Path::new(&model) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or(model); - server::run_server(engine, port, &model_name)?; + server::run_server(engine, port, &model_name)?; + } } Command::Generate { port, seed, prompt_file } => { let prompt = read_prompt(prompt_file)?; diff --git a/server/sr-voice/src/stdio.rs b/server/sr-voice/src/stdio.rs new file mode 100644 index 000000000..8e89668cd --- /dev/null +++ b/server/sr-voice/src/stdio.rs @@ -0,0 +1,69 @@ +//! Stdio JSONL mode for sr-voice (D-138, Spike 2). +//! +//! Reads one JSON object per line from stdin, runs inference, writes one JSON +//! object per line to stdout. No network port is opened — the model is only +//! reachable through the parent process's pipe (Gemma 2 T&C compliance). +//! +//! Request format: {"prompt": "...", "seed": 42} +//! Response format: {"text": "...", "tokens_generated": N, ...} +//! or {"error": "..."} + +use std::io::{self, BufRead, Write}; + +use crate::inference::InferenceEngine; + +const MAX_TOKENS: u32 = 64; +const TEMPERATURE: f32 = 0.7; +const TOP_P: f32 = 0.9; + +#[derive(serde::Deserialize)] +struct StdioRequest { + prompt: String, + seed: Option, +} + +pub fn run_stdio(engine: InferenceEngine) -> Result<(), Box> { + let stdin = io::stdin().lock(); + let mut stdout = io::stdout().lock(); + + for line in stdin.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("stdin read error: {}", e); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let response = match serde_json::from_str::(&line) { + Ok(req) => { + eprintln!(" stdio: {} chars", req.prompt.len()); + match engine.generate(&req.prompt, MAX_TOKENS, TEMPERATURE, TOP_P, req.seed) { + Ok(result) => { + eprintln!( + " -> {} tokens, {:.1} t/s", + result.tokens_generated, result.tokens_per_sec + ); + serde_json::to_string(&result).unwrap() + } + Err(e) => { + serde_json::json!({"error": e.to_string()}).to_string() + } + } + } + Err(e) => { + serde_json::json!({"error": format!("invalid JSON: {}", e)}).to_string() + } + }; + + writeln!(stdout, "{}", response)?; + stdout.flush()?; + } + + eprintln!("sr-voice stdio mode — stdin closed, exiting"); + Ok(()) +} diff --git a/server/src/voice/hardware.rs b/server/src/voice/hardware.rs index 02e2ffd23..3d72b5f63 100644 --- a/server/src/voice/hardware.rs +++ b/server/src/voice/hardware.rs @@ -1,54 +1,40 @@ -//! Hardware detection + dynamic sr-voice instance management (D-138, Spike 2). +//! Hardware detection + dynamic scaling decisions (D-138, Spike 2). //! -//! Determines how many parallel LLM workers the system can sustain and manages -//! sr-voice process lifecycle. The scaling ceiling is conservative: +//! Determines how many parallel LLM workers the system can sustain. +//! The scaling ceiling is conservative: //! //! max_new = (free_resource - existing_llm_usage) / 2 / PER_INSTANCE_COST //! //! "free_resource" is VRAM when a GPU is detected (nvidia-smi / AMD sysfs), //! or system RAM otherwise. This ensures the voice pipeline never takes more //! than half the available headroom after accounting for its own instances. +//! +//! Process lifecycle is handled by `WorkerPool` / `VoicePipe` in `worker.rs`. +//! This module only probes hardware and advises on scaling — it does not +//! spawn or stop sr-voice processes. -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::{Child, Command}; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::process::Command; +use std::time::Duration; use sysinfo::System; -/// RAM budget per sr-voice instance (Gemma 2B Q4_K_M ≈ 1.5 GB resident). +use crate::voice::worker::VoiceProcessConfig; + +/// RAM/VRAM budget per sr-voice instance (Gemma 2B Q4_K_M ≈ 1.5 GB resident). const PER_INSTANCE_RAM_MB: u64 = 1536; -/// Minimum free RAM to allow any voice instance at all. +/// Minimum free resource to allow any voice instance at all. const MIN_FREE_RAM_MB: u64 = 1536; -/// Base port for sr-voice instances. Worker N listens on BASE_PORT + N. -const BASE_PORT: u16 = 8321; - /// Context window size for sr-voice instances. const CTX_SIZE: u32 = 512; -/// How often the scaler thread checks for scale-up/down opportunities. -/// How often the scaler thread checks for scale-up/down opportunities. -/// Used by the runtime scaler loop (not yet implemented). -const _SCALE_CHECK_INTERVAL: Duration = Duration::from_secs(10); - /// Queue depth threshold — sustained above this triggers scale-up consideration. const QUEUE_DEPTH_SCALE_UP: usize = 32; /// Worker idle duration before scale-down. const IDLE_BEFORE_SCALE_DOWN: Duration = Duration::from_secs(60); -/// Managed sr-voice process instances. -#[derive(Debug)] -struct VoiceInstance { - process: Child, - port: u16, - spawned_at: Instant, -} - /// Whether the scaling resource is GPU VRAM or system RAM. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResourceMode { @@ -75,18 +61,16 @@ pub struct HardwareProbe { pub threads_per_instance: u32, } -/// Manages sr-voice process lifecycle and dynamic scaling. -pub struct VoiceInstanceManager { - /// Path to sr-voice binary. - binary_path: PathBuf, - /// Path to model file. - model_path: PathBuf, - /// Running instances keyed by worker ID. - instances: HashMap, - /// Hardware probe from startup. - probe: HardwareProbe, - /// Shutdown signal. - shutdown: Arc, +impl HardwareProbe { + /// Build a `VoiceProcessConfig` from probe results and user paths. + pub fn voice_config(&self, binary_path: String, model_path: String) -> VoiceProcessConfig { + VoiceProcessConfig { + binary_path, + model_path, + threads: self.threads_per_instance, + ctx_size: CTX_SIZE, + } + } } /// Detect GPU type. Run once at install/first-run, persist to settings. @@ -176,6 +160,70 @@ pub fn probe_hardware(mode: Option) -> HardwareProbe { probe } +/// Evaluate whether the worker pool should scale up or down. +/// +/// Called periodically by the voice pipeline coordinator. Does not take +/// action itself — returns a `ScalingDecision` for the caller to act on. +pub fn evaluate_scaling( + mode: ResourceMode, + running_workers: usize, + queue_depth: usize, + active_workers: usize, + max_idle_duration: Option, +) -> ScalingDecision { + // Battery → scale to 1 + if on_battery() && running_workers > 1 { + return ScalingDecision::ScaleDown { + reason: "battery power detected".into(), + }; + } + + // Re-probe free resource for current conditions + let current_free_mb = probe_current_free(mode); + let max_slots = compute_max_slots(current_free_mb, running_workers); + + // Scale up: queue pressure + capacity available + if queue_depth >= QUEUE_DEPTH_SCALE_UP + && running_workers < max_slots + && current_free_mb >= MIN_FREE_RAM_MB + { + return ScalingDecision::ScaleUp { + reason: format!( + "queue depth {} >= {}, {} slots available", + queue_depth, QUEUE_DEPTH_SCALE_UP, max_slots + ), + }; + } + + // Scale down: worker idle too long + more than 1 worker + if running_workers > 1 { + if let Some(idle) = max_idle_duration { + if idle >= IDLE_BEFORE_SCALE_DOWN && active_workers < running_workers { + return ScalingDecision::ScaleDown { + reason: format!("worker idle for {}s", idle.as_secs()), + }; + } + } + } + + ScalingDecision::Hold +} + +/// Result of a scaling evaluation. +#[derive(Debug)] +pub enum ScalingDecision { + /// No change needed. + Hold, + /// Spawn an additional worker (caller decides which). + ScaleUp { reason: String }, + /// Stop an idle worker (caller picks the most idle). + ScaleDown { reason: String }, +} + +// --------------------------------------------------------------------------- +// GPU / resource probing +// --------------------------------------------------------------------------- + /// Probe NVIDIA GPU VRAM via nvidia-smi. /// Returns (total_mb, free_mb) for the first GPU, or None. fn probe_nvidia_vram() -> Option<(u64, u64)> { @@ -323,180 +371,6 @@ fn on_battery() -> bool { false } -impl VoiceInstanceManager { - /// Create a new manager. Does not spawn any instances yet. - pub fn new( - binary_path: PathBuf, - model_path: PathBuf, - probe: HardwareProbe, - shutdown: Arc, - ) -> Self { - Self { - binary_path, - model_path, - instances: HashMap::new(), - probe, - shutdown, - } - } - - /// Number of currently running instances. - pub fn instance_count(&self) -> usize { - self.instances.len() - } - - /// Base port for worker connections. - pub fn base_port(&self) -> u16 { - BASE_PORT - } - - /// The hardware probe from startup. - pub fn probe(&self) -> &HardwareProbe { - &self.probe - } - - /// Spawn a sr-voice instance for the given worker ID. - /// Returns the port it's listening on, or an error. - pub fn spawn_instance(&mut self, worker_id: usize) -> Result { - let port = BASE_PORT + worker_id as u16; - - if self.instances.contains_key(&worker_id) { - return Ok(port); // already running - } - - let child = Command::new(&self.binary_path) - .arg("serve") - .arg("--model") - .arg(&self.model_path) - .arg("--port") - .arg(port.to_string()) - .arg("--threads") - .arg(self.probe.threads_per_instance.to_string()) - .arg("--ctx-size") - .arg(CTX_SIZE.to_string()) - .spawn() - .map_err(|e| format!("failed to spawn sr-voice on port {}: {}", port, e))?; - - tracing::info!(worker_id, port, "spawned sr-voice instance"); - - self.instances.insert(worker_id, VoiceInstance { - process: child, - port, - spawned_at: Instant::now(), - }); - - Ok(port) - } - - /// Stop a sr-voice instance for the given worker ID. - pub fn stop_instance(&mut self, worker_id: usize) { - if let Some(mut instance) = self.instances.remove(&worker_id) { - let _ = instance.process.kill(); - let _ = instance.process.wait(); - tracing::info!(worker_id, port = instance.port, "stopped sr-voice instance"); - } - } - - /// Evaluate whether to scale up or down based on current conditions. - /// - /// Returns (should_scale_up, should_scale_down_worker_id). - pub fn evaluate_scaling( - &self, - queue_depth: usize, - active_workers: usize, - worker_idle_durations: &HashMap, - ) -> ScalingDecision { - // Battery → scale to 1 - if on_battery() && self.instances.len() > 1 { - return ScalingDecision::ScaleDown { - reason: "battery power detected".into(), - }; - } - - // Re-probe free resource (VRAM or RAM) for current conditions - let current_free_mb = probe_current_free(self.probe.mode); - let max_slots = compute_max_slots(current_free_mb, self.instances.len()); - - // Scale up: queue pressure + capacity available - if queue_depth >= QUEUE_DEPTH_SCALE_UP - && self.instances.len() < max_slots - && current_free_mb >= MIN_FREE_RAM_MB - { - return ScalingDecision::ScaleUp { - reason: format!( - "queue depth {} >= {}, {} slots available", - queue_depth, QUEUE_DEPTH_SCALE_UP, max_slots - ), - }; - } - - // Scale down: worker idle too long + more than 1 instance - if self.instances.len() > 1 { - for (&worker_id, &idle_time) in worker_idle_durations { - if idle_time >= IDLE_BEFORE_SCALE_DOWN && active_workers < self.instances.len() { - return ScalingDecision::ScaleDown { - reason: format!( - "worker {} idle for {}s", - worker_id, - idle_time.as_secs() - ), - }; - } - } - } - - ScalingDecision::Hold - } - - /// Shut down all sr-voice instances. - pub fn shutdown_all(&mut self) { - let ids: Vec = self.instances.keys().copied().collect(); - for id in ids { - self.stop_instance(id); - } - } - - /// Wait for a sr-voice instance to become healthy (responds to /health). - /// Returns true if healthy within timeout, false otherwise. - pub fn wait_for_healthy(&self, port: u16, timeout: Duration) -> bool { - let url = format!("http://127.0.0.1:{}/health", port); - let deadline = Instant::now() + timeout; - - while Instant::now() < deadline { - let result = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(1)) - .build() - .get(&url) - .call(); - - if result.is_ok() { - return true; - } - - std::thread::sleep(Duration::from_millis(500)); - } - - false - } -} - -impl Drop for VoiceInstanceManager { - fn drop(&mut self) { - self.shutdown_all(); - } -} - -/// Result of a scaling evaluation. -#[derive(Debug)] -pub enum ScalingDecision { - /// No change needed. - Hold, - /// Spawn an additional instance. - ScaleUp { reason: String }, - /// Stop an instance (pick the most idle worker). - ScaleDown { reason: String }, -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -544,8 +418,7 @@ mod tests { let slots_0 = compute_max_slots(8192, 0); // 8GB free, 0 running let slots_2 = compute_max_slots(8192, 2); // 8GB free, 2 running - // With 2 running, effective headroom is smaller so ceiling is lower per-new-instance - // but total (running + new) can still be higher + // Both should be non-zero with 8GB assert!(slots_0 > 0); assert!(slots_2 > 0); // The key property: free RAM measured at probe time is the same, @@ -561,9 +434,36 @@ mod tests { assert!(probe.threads_per_instance >= 1); } + #[test] + fn voice_config_from_probe() { + let probe = HardwareProbe { + mode: ResourceMode::Cpu, + total_mb: 16384, + free_mb: 8192, + max_slots: 2, + cpu_threads: 8, + threads_per_instance: 2, + }; + let config = probe.voice_config("/usr/bin/sr-voice".into(), "/models/gemma.gguf".into()); + assert_eq!(config.threads, 2); + assert_eq!(config.ctx_size, CTX_SIZE); + } + #[test] fn battery_detection_does_not_crash() { // Just verify it doesn't panic — result depends on hardware let _ = on_battery(); } + + #[test] + fn evaluate_scaling_hold_when_calm() { + let decision = evaluate_scaling( + ResourceMode::Cpu, + 2, // running + 5, // queue depth (low) + 1, // active + None, + ); + assert!(matches!(decision, ScalingDecision::Hold)); + } } diff --git a/server/src/voice/mod.rs b/server/src/voice/mod.rs index 8a88255f9..9db30546c 100644 --- a/server/src/voice/mod.rs +++ b/server/src/voice/mod.rs @@ -9,8 +9,8 @@ //! - `prompt_builder` — composition engine: NPC data + culture + tell state → prompt string //! - `cache` — MessagePack voice cache (store/retrieve, length-gated variants) //! - `queue` — crossbeam work queue with priority + backpressure -//! - `worker` — inference worker pool (dynamic scaling, owns sr-voice HTTP clients) -//! - `hardware` — hardware detection + dynamic sr-voice instance management +//! - `worker` — inference worker pool (each worker owns a piped sr-voice child process) +//! - `hardware` — hardware detection + dynamic scaling decisions pub mod cache; pub mod hardware; diff --git a/server/src/voice/worker.rs b/server/src/voice/worker.rs index f30a8b6c5..44bac9e36 100644 --- a/server/src/voice/worker.rs +++ b/server/src/voice/worker.rs @@ -1,8 +1,12 @@ //! Inference worker pool (D-138, Spike 2). //! -//! Dynamic pool of worker threads, each owning an HTTP client to its own -//! sr-voice instance. Pool size controlled by hardware detection. +//! Dynamic pool of worker threads, each owning a piped stdin/stdout connection +//! to its own sr-voice child process. No network ports — the model is only +//! reachable through the game server's queue (Gemma 2 T&C compliance). +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; @@ -18,12 +22,6 @@ use crate::voice::queue::VoiceRequest; /// Minimum token count for a valid response. Below this, retry once. const MIN_TOKENS: usize = 4; -/// How long to wait before retrying connection to sr-voice. -const _RECONNECT_INTERVAL: Duration = Duration::from_secs(30); - -/// HTTP request timeout for inference calls. -const INFERENCE_TIMEOUT: Duration = Duration::from_secs(60); - /// Worker pool manages inference worker threads. pub struct WorkerPool { workers: Vec, @@ -46,14 +44,20 @@ pub struct WorkerContext { pub paused: Arc, } -use std::collections::HashMap; +/// Configuration for spawning sr-voice child processes. +#[derive(Clone)] +pub struct VoiceProcessConfig { + pub binary_path: String, + pub model_path: String, + pub threads: u32, + pub ctx_size: u32, +} impl WorkerPool { - /// Spawn `count` worker threads, each connecting to sr-voice on - /// `base_port + worker_id`. + /// Spawn `count` worker threads, each owning a piped sr-voice child process. pub fn spawn( count: usize, - base_port: u16, + config: &VoiceProcessConfig, receiver: Receiver, cache: Arc>, cultures: Arc>, @@ -73,11 +77,11 @@ impl WorkerPool { active_count: Arc::clone(&active_count), paused: Arc::clone(&paused), }; - let port = base_port + id as u16; + let cfg = config.clone(); let thread = thread::Builder::new() .name(format!("voice-worker-{}", id)) - .spawn(move || worker_loop(id, port, ctx)) + .spawn(move || worker_loop(id, cfg, ctx)) .expect("failed to spawn voice worker thread"); workers.push(WorkerHandle { @@ -86,7 +90,7 @@ impl WorkerPool { }); } - tracing::info!(count, base_port, "voice worker pool started"); + tracing::info!(count, "voice worker pool started"); Self { workers, @@ -123,13 +127,105 @@ impl Drop for WorkerPool { } } -/// Main worker loop: receive requests, build prompts, call sr-voice, cache results. -fn worker_loop(id: usize, port: u16, ctx: WorkerContext) { - let base_url = format!("http://127.0.0.1:{}", port); - tracing::debug!(id, port, "voice worker started"); +/// A piped connection to a sr-voice child process. +struct VoicePipe { + child: Child, + stdin: ChildStdin, + reader: BufReader, +} - // Below-normal thread priority is handled at the OS level by the - // sr-voice process itself (nice value). Worker threads inherit it. +impl VoicePipe { + /// Spawn a sr-voice child process with piped stdin/stdout. + fn spawn(config: &VoiceProcessConfig) -> Result { + let mut child = Command::new(&config.binary_path) + .arg("serve") + .arg("--model") + .arg(&config.model_path) + .arg("--threads") + .arg(config.threads.to_string()) + .arg("--ctx-size") + .arg(config.ctx_size.to_string()) + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("failed to spawn sr-voice: {}", e))?; + + let stdin = child.stdin.take() + .ok_or_else(|| "failed to capture sr-voice stdin".to_string())?; + let stdout = child.stdout.take() + .ok_or_else(|| "failed to capture sr-voice stdout".to_string())?; + + Ok(Self { + child, + stdin, + reader: BufReader::new(stdout), + }) + } + + /// Send a prompt and read the response (JSONL: one JSON object per line). + fn generate(&mut self, prompt: &str, seed: Option) -> Result { + let request = serde_json::json!({ + "prompt": prompt, + "seed": seed, + }); + + let mut line = serde_json::to_string(&request) + .map_err(|e| format!("failed to serialize request: {}", e))?; + line.push('\n'); + + self.stdin + .write_all(line.as_bytes()) + .map_err(|e| format!("failed to write to sr-voice stdin: {}", e))?; + self.stdin + .flush() + .map_err(|e| format!("failed to flush sr-voice stdin: {}", e))?; + + let mut response_line = String::new(); + self.reader + .read_line(&mut response_line) + .map_err(|e| format!("failed to read from sr-voice stdout: {}", e))?; + + if response_line.is_empty() { + return Err("sr-voice process closed stdout".to_string()); + } + + let body: serde_json::Value = serde_json::from_str(&response_line) + .map_err(|e| format!("failed to parse response JSON: {}", e))?; + + if let Some(err) = body.get("error") { + return Err(format!("sr-voice error: {}", err)); + } + + body["text"] + .as_str() + .map(|s| s.trim().to_string()) + .ok_or_else(|| "response missing 'text' field".to_string()) + } +} + +impl Drop for VoicePipe { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Main worker loop: spawn sr-voice child, receive requests, process them. +fn worker_loop(id: usize, config: VoiceProcessConfig, ctx: WorkerContext) { + tracing::debug!(id, "voice worker starting sr-voice child process"); + + let mut pipe = match VoicePipe::spawn(&config) { + Ok(p) => { + tracing::info!(id, "voice worker connected to sr-voice via stdio"); + p + } + Err(e) => { + tracing::error!(id, error = %e, "voice worker failed to spawn sr-voice — exiting"); + return; + } + }; loop { if ctx.shutdown.load(Ordering::SeqCst) { @@ -145,23 +241,22 @@ fn worker_loop(id: usize, port: u16, ctx: WorkerContext) { // Skip while paused (zone transition) if ctx.paused.load(Ordering::Relaxed) { - // Re-queue the request — it wasn't consumed - let _ = ctx.receiver.clone(); // can't re-send, just drop during pause continue; } ctx.active_count.fetch_add(1, Ordering::Relaxed); - process_request(id, &base_url, &request, &ctx); + process_request(id, &mut pipe, &request, &ctx); ctx.active_count.fetch_sub(1, Ordering::Relaxed); } tracing::debug!(id, "voice worker stopped"); + // VoicePipe::drop kills the child process } /// Process a single voice request: build prompt → infer → validate → cache. fn process_request( worker_id: usize, - base_url: &str, + pipe: &mut VoicePipe, request: &VoiceRequest, ctx: &WorkerContext, ) { @@ -186,8 +281,8 @@ fn process_request( request.seed, ); - // Call sr-voice - let result = call_sr_voice(base_url, &built.prompt); + // Call sr-voice via stdio pipe + let result = pipe.generate(&built.prompt, Some(request.seed)); match result { Ok(text) if text.split_whitespace().count() >= MIN_TOKENS => { @@ -207,12 +302,11 @@ fn process_request( request.tell_state, request.seed.wrapping_add(1), ); - match call_sr_voice(base_url, &retry_built.prompt) { + match pipe.generate(&retry_built.prompt, Some(request.seed.wrapping_add(1))) { Ok(text) if text.split_whitespace().count() >= MIN_TOKENS => { cache_result(request, &text, ctx); } _ => { - // Graceful degradation: cache base text tracing::debug!( worker_id, npc = request.npc_stable_id, @@ -233,34 +327,6 @@ fn process_request( } } -/// POST to sr-voice /generate endpoint and return the generated text. -fn call_sr_voice(base_url: &str, prompt: &str) -> Result { - let url = format!("{}/generate", base_url); - - let payload = serde_json::json!({ "prompt": prompt }); - - let response = ureq::AgentBuilder::new() - .timeout(INFERENCE_TIMEOUT) - .build() - .post(&url) - .send_json(payload); - - match response { - Ok(resp) => { - let body_str = resp - .into_string() - .map_err(|e| format!("failed to read response: {}", e))?; - let body: serde_json::Value = serde_json::from_str(&body_str) - .map_err(|e| format!("failed to parse JSON: {}", e))?; - body["text"] - .as_str() - .map(|s| s.trim().to_string()) - .ok_or_else(|| "response missing 'text' field".to_string()) - } - Err(e) => Err(format!("HTTP error: {}", e)), - } -} - /// Cache the inference result. fn cache_result(request: &VoiceRequest, text: &str, ctx: &WorkerContext) { let key = cache_key_from_request(request);