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