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
+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);
}