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 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 17:57:32 +01:00
co-authored by Claude Opus 4.6
parent be6f2a3db9
commit 0b0fa8c04e
7 changed files with 334 additions and 817 deletions
+7 -535
View File
@@ -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"
-1
View File
@@ -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"
+18 -7
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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)?;
+69
View File
@@ -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<u32>,
}
pub fn run_stdio(engine: InferenceEngine) -> Result<(), Box<dyn std::error::Error>> {
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::<StdioRequest>(&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(())
}
+115 -215
View File
@@ -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<usize, VoiceInstance>,
/// Hardware probe from startup.
probe: HardwareProbe,
/// Shutdown signal.
shutdown: Arc<AtomicBool>,
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<ResourceMode>) -> 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<Duration>,
) -> 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<AtomicBool>,
) -> 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<u16, String> {
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<usize, Duration>,
) -> 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<usize> = 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));
}
}
+2 -2
View File
@@ -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;
+123 -57
View File
@@ -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<WorkerHandle>,
@@ -46,14 +44,20 @@ pub struct WorkerContext {
pub paused: Arc<AtomicBool>,
}
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<VoiceRequest>,
cache: Arc<Mutex<VoiceCacheStore>>,
cultures: Arc<HashMap<String, CultureProfile>>,
@@ -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<ChildStdout>,
}
// 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<Self, String> {
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<u64>) -> Result<String, String> {
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<String, String> {
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);