feat(engine): add sr-voice LLM inference service for NPC voice pipeline

Standalone Rust crate wrapping llama-cpp-2 for GGUF model inference.
Persistent HTTP server architecture — model loaded once, requests
processed sequentially, zero CPU contention by construction.

Subcommands: serve (load model, listen), generate (single prompt),
batch (JSONL), benchmark (5-run average). Makefile targets for
build/serve/run/stop workflow.

Spike 1 validated: Gemma 2B Q4_K_M at ~16 t/s CPU, 4 cultures
tested (Krenn, Ireland, Shek'na, Aranthi), composition-engine
oath injection mechanism proven. GO for Spike 2.

Refs: D-138, #639

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 15:44:37 +01:00
co-authored by Claude Opus 4.6
parent 102b55f64a
commit 1b58d8f949
8 changed files with 1598 additions and 0 deletions
+2
View File
@@ -2,6 +2,8 @@
.cache/
.tmp/
server/target/
server/sr-voice/target/
server/models/
tooling/content-converter/target/
tooling/line-previewer/target/
tooling/test-client/target/
+24
View File
@@ -7,6 +7,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
checklist-validate checklist-generate \
build-sr-voice run-sr-voice \
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
screenshot visual-movie test-visual visual-update
@@ -65,6 +66,10 @@ help:
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
@echo ""
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
@echo " make build-sr-voice Build sr-voice LLM inference service"
@echo " make serve-sr-voice Start sr-voice server (ARGS='--model <path>')"
@echo " make run-sr-voice Submit to sr-voice server (ARGS='generate|batch|benchmark ...')"
@echo " make stop-sr-voice Stop sr-voice server"
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -344,6 +349,25 @@ test-visual:
visual-update:
@tests/run-visual --update
LIBCLANG_PATH ?= /usr/lib64/rocm/llvm/lib
BINDGEN_CLANG_ARGS ?= -I/usr/lib64/rocm/llvm/lib/clang/19/include
SR_VOICE_ENV = LIBCLANG_PATH=$(LIBCLANG_PATH) BINDGEN_EXTRA_CLANG_ARGS="$(BINDGEN_CLANG_ARGS)"
SR_VOICE_PORT ?= 8321
build-sr-voice:
cd server/sr-voice && $(SR_VOICE_ENV) cargo build --release
serve-sr-voice:
cd server/sr-voice && $(SR_VOICE_ENV) cargo run --release -- serve $(ARGS)
run-sr-voice:
cd server/sr-voice && $(SR_VOICE_ENV) cargo run --release -- $(ARGS)
stop-sr-voice:
@lsof -ti :$(SR_VOICE_PORT) | xargs -r kill 2>/dev/null || true
@echo "Stopped sr-voice on port $(SR_VOICE_PORT)"
content-ron:
cd tooling/content-converter && cargo build --release
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
+964
View File
@@ -0,0 +1,964 @@
# This file is automatically @generated by Cargo.
# 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "find_cuda_helper"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f9e65c593dd01ac77daad909ea4ad17f0d6d1776193fc8ea766356177abdad"
dependencies = [
"glob",
]
[[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 = "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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "llama-cpp-2"
version = "0.1.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2947ab625c59d1fdf42e61f538c3fa66f43de2f78316971920873f359483d1d8"
dependencies = [
"encoding_rs",
"enumflags2",
"llama-cpp-sys-2",
"thiserror",
"tracing",
"tracing-core",
]
[[package]]
name = "llama-cpp-sys-2"
version = "0.1.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a529006bf16af70c7485ba957820dc2bc9467d75697e97970c81d2da73c76f"
dependencies = [
"bindgen",
"cc",
"cmake",
"find_cuda_helper",
"glob",
"walkdir",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[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 = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[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 = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[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 = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[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 = "sr-voice"
version = "0.1.0"
dependencies = [
"clap",
"llama-cpp-2",
"serde",
"serde_json",
"thiserror",
"tiny_http",
"ureq",
]
[[package]]
name = "strsim"
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.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf-8",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[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 = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[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_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 = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "sr-voice"
version = "0.1.0"
edition = "2021"
[dependencies]
llama-cpp-2 = "0.1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tiny_http = "0.12"
ureq = "3"
[features]
default = []
metal = ["llama-cpp-2/metal"]
vulkan = ["llama-cpp-2/vulkan"]
cuda = ["llama-cpp-2/cuda"]
rocm = ["llama-cpp-2/rocm"]
+165
View File
@@ -0,0 +1,165 @@
use std::num::NonZeroU32;
use std::path::Path;
use std::time::Instant;
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaModel, Special};
use llama_cpp_2::sampling::LlamaSampler;
use crate::VoiceError;
/// Configuration for model loading and inference.
pub struct InferenceConfig {
pub model_path: String,
pub threads: u32,
pub ctx_size: u32,
pub seed: Option<u32>,
}
/// Result of a single generation call.
#[derive(serde::Serialize)]
pub struct GenerationResult {
pub text: String,
pub tokens_generated: u32,
pub generation_time_ms: u64,
pub tokens_per_sec: f64,
pub prefill_time_ms: u64,
}
/// Wraps llama.cpp model and context for text generation.
pub struct InferenceEngine {
backend: LlamaBackend,
model: LlamaModel,
ctx_size: u32,
threads: u32,
}
impl InferenceEngine {
/// Load a GGUF model from disk.
pub fn load(config: &InferenceConfig) -> Result<Self, VoiceError> {
let backend =
LlamaBackend::init().map_err(|e| VoiceError::ModelLoadFailed(e.to_string()))?;
let model_params = LlamaModelParams::default();
let model = LlamaModel::load_from_file(
&backend,
Path::new(&config.model_path),
&model_params,
)
.map_err(|e| VoiceError::ModelLoadFailed(e.to_string()))?;
Ok(Self {
backend,
model,
ctx_size: config.ctx_size,
threads: config.threads,
})
}
/// Generate text from a prompt.
pub fn generate(
&self,
prompt: &str,
max_tokens: u32,
temperature: f32,
top_p: f32,
seed: Option<u32>,
) -> Result<GenerationResult, VoiceError> {
let ctx_params = LlamaContextParams::default()
.with_n_ctx(NonZeroU32::new(self.ctx_size))
.with_n_threads(self.threads as i32)
.with_n_threads_batch(self.threads as i32);
let mut ctx = self
.model
.new_context(&self.backend, ctx_params)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
// Tokenize the prompt
let tokens = self
.model
.str_to_token(prompt, AddBos::Always)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
if tokens.len() as u32 >= self.ctx_size {
return Err(VoiceError::InferenceFailed(format!(
"Prompt ({} tokens) exceeds context size ({})",
tokens.len(),
self.ctx_size
)));
}
// Prefill: evaluate the prompt tokens
let prefill_start = Instant::now();
let mut batch = LlamaBatch::new(self.ctx_size as usize, 1);
for (i, &token) in tokens.iter().enumerate() {
let is_last = i == tokens.len() - 1;
batch
.add(token, i as i32, &[0], is_last)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
}
ctx.decode(&mut batch)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
let prefill_time_ms = prefill_start.elapsed().as_millis() as u64;
// Generation loop
let gen_start = Instant::now();
let mut generated_tokens: u32 = 0;
let mut output = String::new();
let mut cur_pos = tokens.len() as i32;
let mut sampler = LlamaSampler::chain_simple([
LlamaSampler::temp(temperature),
LlamaSampler::top_p(top_p, 1),
LlamaSampler::dist(seed.unwrap_or(1234)),
]);
loop {
if generated_tokens >= max_tokens {
break;
}
let logits_index = batch.n_tokens() - 1;
let token = sampler.sample(&ctx, logits_index);
if self.model.is_eog_token(token) {
break;
}
#[allow(deprecated)]
let piece = self
.model
.token_to_str(token, Special::Tokenize)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
output.push_str(&piece);
generated_tokens += 1;
batch.clear();
batch
.add(token, cur_pos, &[0], true)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
cur_pos += 1;
ctx.decode(&mut batch)
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
}
let generation_time_ms = gen_start.elapsed().as_millis() as u64;
let tokens_per_sec = if generation_time_ms > 0 {
(generated_tokens as f64 / generation_time_ms as f64) * 1000.0
} else {
0.0
};
Ok(GenerationResult {
text: output,
tokens_generated: generated_tokens,
generation_time_ms,
tokens_per_sec,
prefill_time_ms,
})
}
}
+248
View File
@@ -0,0 +1,248 @@
mod inference;
mod prompt;
mod server;
use std::io::Read;
use std::time::{Duration, Instant};
use clap::{Parser, Subcommand};
use inference::{InferenceConfig, InferenceEngine};
/// Errors for the sr-voice CLI.
#[derive(thiserror::Error, Debug)]
pub enum VoiceError {
#[error("model load failed: {0}")]
ModelLoadFailed(String),
#[error("inference failed: {0}")]
InferenceFailed(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
/// sr-voice — LLM inference service for The Settled Reach
#[derive(Parser)]
#[command(name = "sr-voice", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Start the inference server (loads model, listens for requests)
Serve {
/// Path to GGUF model file
#[arg(long)]
model: String,
/// Listen port
#[arg(long, default_value = "8321")]
port: u16,
/// CPU threads for inference
#[arg(long)]
threads: Option<u32>,
/// Context window size in tokens
#[arg(long, default_value = "512")]
ctx_size: u32,
},
/// Generate text from a single prompt (requires running server)
Generate {
/// Server port
#[arg(long, default_value = "8321")]
port: u16,
/// RNG seed
#[arg(long)]
seed: Option<u32>,
/// Prompt file (reads from stdin if omitted)
prompt_file: Option<String>,
},
/// Process a JSONL batch of prompts (requires running server)
Batch {
/// Server port
#[arg(long, default_value = "8321")]
port: u16,
/// Input JSONL file
#[arg(long)]
input: String,
},
/// Run 5 inferences and report average tokens/sec (requires running server)
Benchmark {
/// Server port
#[arg(long, default_value = "8321")]
port: u16,
},
}
fn default_threads() -> u32 {
let cores = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(4);
cores.saturating_sub(1).max(1)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Command::Serve { model, port, threads, ctx_size } => {
let threads = threads.unwrap_or_else(default_threads);
let config = InferenceConfig {
model_path: model.clone(),
threads,
ctx_size,
seed: None,
};
eprintln!("Loading model: {}", config.model_path);
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);
server::run_server(engine, port, &model_name)?;
}
Command::Generate { port, seed, prompt_file } => {
let prompt = read_prompt(prompt_file)?;
let req = serde_json::json!({ "prompt": prompt, "seed": seed });
let body = post_with_status(port, "/generate", &req.to_string())?;
let result: serde_json::Value = serde_json::from_str(&body)?;
if let Some(err) = result.get("error") {
return Err(format!("Server error: {}", err).into());
}
println!("{}", result["text"].as_str().unwrap_or(""));
eprintln!(
"[{} tokens in {}ms — {:.1} t/s, prefill {}ms]",
result["tokens_generated"],
result["generation_time_ms"],
result["tokens_per_sec"].as_f64().unwrap_or(0.0),
result["prefill_time_ms"],
);
}
Command::Batch { port, input } => {
let file = std::fs::File::open(&input)?;
let reader = std::io::BufReader::new(file);
let payloads = prompt::parse_jsonl(reader)?;
let body = post_with_status(port, "/batch", &serde_json::to_string(&payloads)?)?;
for line in body.lines() {
if line.is_empty() { continue; }
let result: serde_json::Value = serde_json::from_str(line)?;
let id = result["id"].as_str().unwrap_or("?");
if let Some(err) = result.get("error") {
eprintln!("--- {} --- ERROR: {}", id, err);
} else {
println!("--- {} ---", id);
println!("{}", result["text"].as_str().unwrap_or(""));
eprintln!(
"[{} tokens in {}ms — {:.1} t/s]",
result["tokens_generated"],
result["generation_time_ms"],
result["tokens_per_sec"].as_f64().unwrap_or(0.0),
);
}
}
}
Command::Benchmark { port } => {
let prompt = "Rephrase in terse dialect: The worker tends the crops in the field.";
let runs = 5;
eprintln!("Benchmark: {} runs", runs);
let mut total_tps = 0.0;
let mut total_prefill = 0u64;
let mut total_gen = 0u64;
for i in 0..runs {
let req = serde_json::json!({ "prompt": prompt });
let body = post_with_status(port, "/generate", &req.to_string())?;
let result: serde_json::Value = serde_json::from_str(&body)?;
let tps = result["tokens_per_sec"].as_f64().unwrap_or(0.0);
let prefill = result["prefill_time_ms"].as_u64().unwrap_or(0);
let gen = result["generation_time_ms"].as_u64().unwrap_or(0);
let tokens = result["tokens_generated"].as_u64().unwrap_or(0);
eprintln!(" run {}: {} tokens, {:.1} t/s, prefill {}ms", i + 1, tokens, tps, prefill);
total_tps += tps;
total_prefill += prefill;
total_gen += gen;
}
eprintln!("\n=== Benchmark Results ===");
eprintln!(" Avg tokens/sec: {:.1}", total_tps / runs as f64);
eprintln!(" Avg prefill: {}ms", total_prefill / runs);
eprintln!(" Avg generation: {}ms", total_gen / runs);
}
}
Ok(())
}
fn read_prompt(prompt_file: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
let raw = match prompt_file {
Some(path) => std::fs::read_to_string(&path)?,
None => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
}
};
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
return Err("No prompt provided".into());
}
Ok(trimmed)
}
/// POST to the server. Prints "Server is processing..." if response takes > 500ms.
fn post_with_status(port: u16, path: &str, body: &str) -> Result<String, Box<dyn std::error::Error>> {
let base = format!("http://127.0.0.1:{}", port);
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(600)))
.timeout_connect(Some(Duration::from_secs(2)))
.build()
.new_agent();
// Health check — clear error if server isn't running
if agent.get(&format!("{}/health", base)).call().is_err() {
return Err(format!(
"No sr-voice server on port {}. Start one with: sr-voice serve --model <path>",
port
).into());
}
let url = format!("{}{}", base, path);
let start = Instant::now();
let printed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = printed.clone();
let handle = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(500));
if !flag.load(std::sync::atomic::Ordering::Relaxed) {
eprint!("Server is processing...");
flag.store(true, std::sync::atomic::Ordering::Relaxed);
}
});
let result = agent.post(&url)
.header("Content-Type", "application/json")
.send(body);
let was_printed = printed.load(std::sync::atomic::Ordering::Relaxed);
printed.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.join();
if was_printed {
eprintln!(" done ({:.1}s)", start.elapsed().as_secs_f64());
}
match result {
Ok(response) => {
let text = response.into_body().read_to_string()?;
Ok(text)
}
Err(e) => Err(format!("Request failed: {}", e).into()),
}
}
+41
View File
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use crate::VoiceError;
/// Content types for voice generation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentType {
Behavior,
Dialogue,
Tell,
}
/// A single prompt payload, used in batch JSONL mode.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptPayload {
pub id: String,
pub content_type: ContentType,
pub prompt: String,
#[serde(default)]
pub base_text: Option<String>,
#[serde(default)]
pub semantic_core: Option<String>,
}
/// Parse a JSONL file into a list of prompt payloads.
pub fn parse_jsonl(reader: impl BufRead) -> Result<Vec<PromptPayload>, VoiceError> {
let mut payloads = Vec::new();
for (i, line) in reader.lines().enumerate() {
let line = line.map_err(|e| VoiceError::InvalidInput(format!("line {}: {}", i + 1, e)))?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let payload: PromptPayload = serde_json::from_str(trimmed)
.map_err(|e| VoiceError::InvalidInput(format!("line {}: {}", i + 1, e)))?;
payloads.push(payload);
}
Ok(payloads)
}
+134
View File
@@ -0,0 +1,134 @@
use std::time::Instant;
use tiny_http::{Header, Method, Response, Server};
use crate::inference::InferenceEngine;
use crate::prompt::PromptPayload;
const MAX_TOKENS: u32 = 64;
const TEMPERATURE: f32 = 0.7;
const TOP_P: f32 = 0.9;
#[derive(serde::Deserialize)]
struct GenerateRequest {
prompt: String,
seed: Option<u32>,
}
pub fn run_server(
engine: InferenceEngine,
port: u16,
model_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let addr = format!("127.0.0.1:{}", port);
let server = Server::http(&addr)
.map_err(|e| format!("Failed to bind {}: {}", addr, e))?;
let start = Instant::now();
eprintln!("sr-voice server ready on http://{}", addr);
eprintln!(" model: {}", model_name);
eprintln!(" POST /generate POST /batch GET /health");
for request in server.incoming_requests() {
let path = request.url().to_string();
let method = request.method().clone();
match (method, path.as_str()) {
(Method::Get, "/health") => {
let body = serde_json::json!({
"status": "ready",
"model": model_name,
"uptime_secs": start.elapsed().as_secs(),
});
respond(request, 200, &body.to_string());
}
(Method::Post, "/generate") => handle_generate(&engine, request),
(Method::Post, "/batch") => handle_batch(&engine, request),
_ => {
respond(request, 404, &serde_json::json!({"error": "not found"}).to_string());
}
}
}
Ok(())
}
fn handle_generate(engine: &InferenceEngine, mut request: tiny_http::Request) {
let mut body = String::new();
if std::io::Read::read_to_string(request.as_reader(), &mut body).is_err() {
respond(request, 400, r#"{"error":"failed to read body"}"#);
return;
}
let req: GenerateRequest = match serde_json::from_str(&body) {
Ok(r) => r,
Err(e) => {
let msg = serde_json::json!({"error": format!("invalid JSON: {}", e)});
respond(request, 400, &msg.to_string());
return;
}
};
eprintln!(" generate: {} 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);
respond(request, 200, &serde_json::to_string(&result).unwrap());
}
Err(e) => {
let msg = serde_json::json!({"error": e.to_string()});
respond(request, 500, &msg.to_string());
}
}
}
fn handle_batch(engine: &InferenceEngine, mut request: tiny_http::Request) {
let mut body = String::new();
if std::io::Read::read_to_string(request.as_reader(), &mut body).is_err() {
respond(request, 400, r#"{"error":"failed to read body"}"#);
return;
}
let payloads: Vec<PromptPayload> = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
let msg = serde_json::json!({"error": format!("invalid JSON: {}", e)});
respond(request, 400, &msg.to_string());
return;
}
};
eprintln!(" batch: {} prompts", payloads.len());
let mut output = String::new();
for payload in &payloads {
match engine.generate(&payload.prompt, MAX_TOKENS, TEMPERATURE, TOP_P, None) {
Ok(result) => {
eprintln!(" -> {}: {} tokens, {:.1} t/s", payload.id, result.tokens_generated, result.tokens_per_sec);
#[derive(serde::Serialize)]
struct BatchLine<'a> {
id: &'a str,
#[serde(flatten)]
result: &'a crate::inference::GenerationResult,
}
let line = serde_json::to_string(&BatchLine { id: &payload.id, result: &result }).unwrap();
output.push_str(&line);
output.push('\n');
}
Err(e) => {
let line = serde_json::json!({"id": payload.id, "error": e.to_string()});
output.push_str(&line.to_string());
output.push('\n');
}
}
}
respond(request, 200, &output);
}
fn respond(request: tiny_http::Request, status: u16, body: &str) {
let header = Header::from_bytes("Content-Type", "application/json").unwrap();
let response = Response::from_string(body)
.with_status_code(status)
.with_header(header);
let _ = request.respond(response);
}