feat(voice): add cache, queue, and worker modules (D-138, Spike 2 Phase 2)
MessagePack voice cache with per-zone persistence and version invalidation. Priority work queue with crossbeam bounded channel, backpressure, pause/resume, and zone-change reprioritization. Inference worker pool with empty output guard and graceful degradation to base text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
//! MessagePack voice cache (D-138, Spike 2).
|
||||
//!
|
||||
//! Stores re-voiced text keyed by (npc, content, tell state, culture).
|
||||
//! Length-gated variant count: short lines cache neutral only, medium lines
|
||||
//! cache 3 variants, long lines cache all applicable tells.
|
||||
//!
|
||||
//! Baked content is just pre-populated cache — `make voice-bake` writes to
|
||||
//! the same directory. No separate baked path.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::npc::tell_state::TellCategory;
|
||||
use crate::voice::prompt_builder::ContentType;
|
||||
|
||||
/// Cache key for a single voiced line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct CacheKey {
|
||||
pub culture_id: String,
|
||||
pub npc_stable_id: u64,
|
||||
pub content_type: ContentType,
|
||||
pub content_index: u16,
|
||||
/// Tell state variant. `None` = neutral (used for short content).
|
||||
pub tell_state: Option<TellCategory>,
|
||||
}
|
||||
|
||||
/// A single zone's voice cache — maps cache keys to voiced text.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ZoneVoiceCache {
|
||||
/// Model version hash — cache miss if this doesn't match.
|
||||
pub model_version: String,
|
||||
/// Injector version hash — cache miss if this doesn't match.
|
||||
pub injector_version: String,
|
||||
/// Cached voiced lines.
|
||||
pub entries: HashMap<CacheKey, String>,
|
||||
}
|
||||
|
||||
impl ZoneVoiceCache {
|
||||
pub fn new(model_version: String, injector_version: String) -> Self {
|
||||
Self {
|
||||
model_version,
|
||||
injector_version,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a cached voiced line. Returns `None` on miss.
|
||||
pub fn lookup(&self, key: &CacheKey) -> Option<&str> {
|
||||
self.entries.get(key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Store a voiced line in the cache.
|
||||
pub fn store(&mut self, key: CacheKey, text: String) {
|
||||
self.entries.insert(key, text);
|
||||
}
|
||||
|
||||
/// Number of cached entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages voice caches across zones with disk persistence.
|
||||
#[derive(Debug)]
|
||||
pub struct VoiceCacheStore {
|
||||
/// Base directory for cache files.
|
||||
base_dir: PathBuf,
|
||||
/// World seed — part of the directory path.
|
||||
world_seed: u64,
|
||||
/// Current model version hash.
|
||||
model_version: String,
|
||||
/// Current injector version hash.
|
||||
injector_version: String,
|
||||
/// Loaded zone caches.
|
||||
zones: HashMap<u32, ZoneVoiceCache>,
|
||||
}
|
||||
|
||||
impl VoiceCacheStore {
|
||||
/// Create a new cache store. Does not load any zones yet.
|
||||
pub fn new(
|
||||
base_dir: PathBuf,
|
||||
world_seed: u64,
|
||||
model_version: String,
|
||||
injector_version: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_dir,
|
||||
world_seed,
|
||||
model_version,
|
||||
injector_version,
|
||||
zones: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or load the cache for a zone.
|
||||
pub fn zone_cache(&mut self, zone_id: u32) -> &mut ZoneVoiceCache {
|
||||
if !self.zones.contains_key(&zone_id) {
|
||||
let cache = self.load_zone(zone_id).unwrap_or_else(|| {
|
||||
ZoneVoiceCache::new(
|
||||
self.model_version.clone(),
|
||||
self.injector_version.clone(),
|
||||
)
|
||||
});
|
||||
self.zones.insert(zone_id, cache);
|
||||
}
|
||||
self.zones.get_mut(&zone_id).unwrap()
|
||||
}
|
||||
|
||||
/// Look up a voiced line across the right zone cache.
|
||||
pub fn lookup(&mut self, zone_id: u32, key: &CacheKey) -> Option<String> {
|
||||
let cache = self.zone_cache(zone_id);
|
||||
cache.lookup(key).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Store a voiced line and return the stored text.
|
||||
pub fn store(&mut self, zone_id: u32, key: CacheKey, text: String) {
|
||||
let cache = self.zone_cache(zone_id);
|
||||
cache.store(key, text);
|
||||
}
|
||||
|
||||
/// Persist a zone's cache to disk as MessagePack.
|
||||
pub fn save_zone(&self, zone_id: u32) -> io::Result<()> {
|
||||
let Some(cache) = self.zones.get(&zone_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let dir = self.zone_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join(format!("{}.msgpack", zone_id));
|
||||
let data = rmp_serde::to_vec(cache)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
fs::write(path, data)
|
||||
}
|
||||
|
||||
/// Save all loaded zone caches to disk.
|
||||
pub fn save_all(&self) -> io::Result<()> {
|
||||
for &zone_id in self.zones.keys() {
|
||||
self.save_zone(zone_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a zone cache from disk. Returns `None` if file doesn't exist
|
||||
/// or version mismatch (cache invalidation).
|
||||
fn load_zone(&self, zone_id: u32) -> Option<ZoneVoiceCache> {
|
||||
let path = self.zone_dir().join(format!("{}.msgpack", zone_id));
|
||||
let data = fs::read(&path).ok()?;
|
||||
let cache: ZoneVoiceCache = rmp_serde::from_slice(&data).ok()?;
|
||||
|
||||
// Version check — invalidate on mismatch
|
||||
if cache.model_version != self.model_version
|
||||
|| cache.injector_version != self.injector_version
|
||||
{
|
||||
tracing::info!(
|
||||
zone_id,
|
||||
"voice cache version mismatch — invalidating"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
tracing::debug!(zone_id, entries = cache.entries.len(), "loaded voice cache");
|
||||
Some(cache)
|
||||
}
|
||||
|
||||
fn zone_dir(&self) -> PathBuf {
|
||||
self.base_dir.join(format!("{}", self.world_seed))
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine which tell states should be cached for a given base text.
|
||||
///
|
||||
/// Length-gated variant count (Spike 1 finding):
|
||||
/// - Short (≤7 words): neutral only — 2B model can't differentiate
|
||||
/// - Medium (8–15 words): neutral + Angry + Guarded (3 variants)
|
||||
/// - Long (16+ words): all 5 tells + neutral (6 variants)
|
||||
pub fn cacheable_tells(base_text: &str) -> Vec<Option<TellCategory>> {
|
||||
let words = base_text.split_whitespace().count();
|
||||
if words <= 7 {
|
||||
vec![None] // neutral only
|
||||
} else if words <= 15 {
|
||||
vec![None, Some(TellCategory::Angry), Some(TellCategory::Guarded)]
|
||||
} else {
|
||||
vec![
|
||||
None,
|
||||
Some(TellCategory::Nervous),
|
||||
Some(TellCategory::Angry),
|
||||
Some(TellCategory::Friendly),
|
||||
Some(TellCategory::Guarded),
|
||||
Some(TellCategory::RoutineDeviation),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Make ContentType serializable for cache keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Serialize for ContentType {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
ContentType::Dialogue => serializer.serialize_u8(0),
|
||||
ContentType::Behavior => serializer.serialize_u8(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentType {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let v = u8::deserialize(deserializer)?;
|
||||
match v {
|
||||
0 => Ok(ContentType::Dialogue),
|
||||
1 => Ok(ContentType::Behavior),
|
||||
_ => Err(serde::de::Error::custom("invalid ContentType")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zone_cache_store_and_lookup() {
|
||||
let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into());
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 42,
|
||||
content_type: ContentType::Dialogue,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
};
|
||||
cache.store(key.clone(), "Look, that's not mine to say.".into());
|
||||
assert_eq!(cache.lookup(&key), Some("Look, that's not mine to say."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_cache_miss_returns_none() {
|
||||
let cache = ZoneVoiceCache::new("v1".into(), "i1".into());
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 42,
|
||||
content_type: ContentType::Dialogue,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
};
|
||||
assert_eq!(cache.lookup(&key), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_cache_round_trips_through_msgpack() {
|
||||
let mut cache = ZoneVoiceCache::new("v1".into(), "i1".into());
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 42,
|
||||
content_type: ContentType::Behavior,
|
||||
content_index: 3,
|
||||
tell_state: Some(TellCategory::Nervous),
|
||||
};
|
||||
cache.store(key.clone(), "Hands are steady. Eyes aren't.".into());
|
||||
|
||||
let data = rmp_serde::to_vec(&cache).unwrap();
|
||||
let restored: ZoneVoiceCache = rmp_serde::from_slice(&data).unwrap();
|
||||
assert_eq!(restored.lookup(&key), Some("Hands are steady. Eyes aren't."));
|
||||
assert_eq!(restored.model_version, "v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_store_persists_and_loads() {
|
||||
let dir = std::env::temp_dir().join("sr-voice-cache-test");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
let mut store = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into());
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 1,
|
||||
content_type: ContentType::Dialogue,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
};
|
||||
store.store(100, key.clone(), "Hey.".into());
|
||||
store.save_zone(100).unwrap();
|
||||
|
||||
// New store instance — loads from disk
|
||||
let mut store2 = VoiceCacheStore::new(dir.clone(), 12345, "v1".into(), "i1".into());
|
||||
assert_eq!(store2.lookup(100, &key), Some("Hey.".into()));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_invalidation_on_version_mismatch() {
|
||||
let dir = std::env::temp_dir().join("sr-voice-cache-invalidation-test");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
let mut store = VoiceCacheStore::new(dir.clone(), 42, "v1".into(), "i1".into());
|
||||
let key = CacheKey {
|
||||
culture_id: "krenn".into(),
|
||||
npc_stable_id: 1,
|
||||
content_type: ContentType::Dialogue,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
};
|
||||
store.store(1, key.clone(), "Old text.".into());
|
||||
store.save_zone(1).unwrap();
|
||||
|
||||
// Different model version — should invalidate
|
||||
let mut store2 = VoiceCacheStore::new(dir.clone(), 42, "v2".into(), "i1".into());
|
||||
assert_eq!(store2.lookup(1, &key), None);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_tells_short() {
|
||||
let tells = cacheable_tells("Hello there.");
|
||||
assert_eq!(tells.len(), 1);
|
||||
assert_eq!(tells[0], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_tells_medium() {
|
||||
let tells = cacheable_tells("The overnight delivery came in clean and it was logged");
|
||||
assert_eq!(tells.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cacheable_tells_long() {
|
||||
let tells = cacheable_tells(
|
||||
"I heard the night crew had to stop the line twice because the coupling was faulty and nobody had flagged it"
|
||||
);
|
||||
assert_eq!(tells.len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,7 @@
|
||||
//! - `worker` — inference worker pool (dynamic scaling, owns sr-voice HTTP clients)
|
||||
//! - `hardware` — hardware detection + dynamic sr-voice instance management
|
||||
|
||||
pub mod cache;
|
||||
pub mod prompt_builder;
|
||||
pub mod queue;
|
||||
pub mod worker;
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Voice pipeline work queue (D-138, Spike 2).
|
||||
//!
|
||||
//! Bounded crossbeam channel with priority ordering and backpressure.
|
||||
//! Queue full → request dropped silently, game serves base text.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, TrySendError};
|
||||
|
||||
use crate::npc::tell_state::TellCategory;
|
||||
use crate::voice::prompt_builder::ContentType;
|
||||
|
||||
/// Queue capacity — requests beyond this are dropped (backpressure).
|
||||
const QUEUE_CAPACITY: usize = 256;
|
||||
|
||||
/// Priority levels for voice requests.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Priority {
|
||||
/// P0 — plot-critical NPC in current zone.
|
||||
Critical,
|
||||
/// P1 — current zone NPCs.
|
||||
High,
|
||||
/// P2 — adjacent zone pre-voicing.
|
||||
Standard,
|
||||
/// P3 — distant zones.
|
||||
Background,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
/// Lower number = higher priority (for min-heap ordering).
|
||||
fn rank(self) -> u8 {
|
||||
match self {
|
||||
Priority::Critical => 0,
|
||||
Priority::High => 1,
|
||||
Priority::Standard => 2,
|
||||
Priority::Background => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request to re-voice a piece of content.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceRequest {
|
||||
pub priority: Priority,
|
||||
pub npc_stable_id: u64,
|
||||
pub zone_id: u32,
|
||||
pub culture_id: String,
|
||||
pub base_text: String,
|
||||
pub content_type: ContentType,
|
||||
pub content_index: u16,
|
||||
pub tell_state: Option<TellCategory>,
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
/// Wrapper for priority ordering in the heap (higher priority = dequeued first).
|
||||
impl PartialEq for VoiceRequest {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.priority.rank() == other.priority.rank()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for VoiceRequest {}
|
||||
|
||||
impl PartialOrd for VoiceRequest {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for VoiceRequest {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Reverse: lower rank = higher priority = should come first
|
||||
other.priority.rank().cmp(&self.priority.rank())
|
||||
}
|
||||
}
|
||||
|
||||
/// Priority-ordered voice work queue with backpressure.
|
||||
///
|
||||
/// Uses a crossbeam bounded channel as transport between the game thread
|
||||
/// and worker pool, with a priority heap on the consumer side.
|
||||
pub struct VoiceQueue {
|
||||
sender: Sender<VoiceRequest>,
|
||||
receiver: Receiver<VoiceRequest>,
|
||||
paused: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl VoiceQueue {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = crossbeam_channel::bounded(QUEUE_CAPACITY);
|
||||
Self {
|
||||
sender,
|
||||
receiver,
|
||||
paused: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a voice request. Returns `false` if the queue is full (backpressure).
|
||||
pub fn submit(&self, request: VoiceRequest) -> bool {
|
||||
match self.sender.try_send(request) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(_)) => {
|
||||
tracing::trace!("voice queue full — dropping request");
|
||||
false
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
tracing::warn!("voice queue disconnected");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a clone of the receiver for worker threads.
|
||||
pub fn receiver(&self) -> Receiver<VoiceRequest> {
|
||||
self.receiver.clone()
|
||||
}
|
||||
|
||||
/// Current number of pending requests in the channel.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.sender.len()
|
||||
}
|
||||
|
||||
/// Pause the queue (zone transition start).
|
||||
pub fn pause(&self) {
|
||||
if let Ok(mut p) = self.paused.lock() {
|
||||
*p = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume the queue (zone transition complete).
|
||||
pub fn resume(&self) {
|
||||
if let Ok(mut p) = self.paused.lock() {
|
||||
*p = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the queue is paused.
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused.lock().map(|p| *p).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reprioritize all pending requests after a zone change.
|
||||
///
|
||||
/// Drains the channel, re-tags each request's priority using the
|
||||
/// provided closure, and re-submits. Requests that no longer fit
|
||||
/// (queue full after re-submission) are dropped — same backpressure
|
||||
/// rule as normal submission.
|
||||
///
|
||||
/// Call this between `pause()` and `resume()` during zone transitions
|
||||
/// so workers don't consume stale-priority requests mid-reshuffle.
|
||||
pub fn reprioritize<F>(&self, mut classify: F)
|
||||
where
|
||||
F: FnMut(&VoiceRequest) -> Priority,
|
||||
{
|
||||
// Drain all pending requests
|
||||
let mut pending = Vec::new();
|
||||
while let Ok(req) = self.receiver.try_recv() {
|
||||
pending.push(req);
|
||||
}
|
||||
|
||||
let count = pending.len();
|
||||
let mut resubmitted = 0;
|
||||
|
||||
// Re-tag and re-submit
|
||||
for mut req in pending {
|
||||
req.priority = classify(&req);
|
||||
if self.submit(req) {
|
||||
resubmitted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
tracing::debug!(
|
||||
drained = count,
|
||||
resubmitted,
|
||||
dropped = count - resubmitted,
|
||||
"voice queue reprioritized after zone change"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Priority drain: collect all pending items from the channel into a
|
||||
/// priority-ordered heap, then drain highest-priority first.
|
||||
///
|
||||
/// Used by workers to process the most important requests first when
|
||||
/// multiple requests are queued.
|
||||
pub struct PriorityDrain {
|
||||
heap: BinaryHeap<VoiceRequest>,
|
||||
}
|
||||
|
||||
impl PriorityDrain {
|
||||
/// Drain all currently available items from the receiver into the heap.
|
||||
pub fn from_receiver(receiver: &Receiver<VoiceRequest>) -> Self {
|
||||
let mut heap = BinaryHeap::new();
|
||||
while let Ok(req) = receiver.try_recv() {
|
||||
heap.push(req);
|
||||
}
|
||||
Self { heap }
|
||||
}
|
||||
|
||||
/// Pop the highest-priority request.
|
||||
pub fn pop(&mut self) -> Option<VoiceRequest> {
|
||||
self.heap.pop()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.heap.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.heap.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request(priority: Priority, text: &str) -> VoiceRequest {
|
||||
VoiceRequest {
|
||||
priority,
|
||||
npc_stable_id: 1,
|
||||
zone_id: 100,
|
||||
culture_id: "krenn".into(),
|
||||
base_text: text.into(),
|
||||
content_type: ContentType::Dialogue,
|
||||
content_index: 0,
|
||||
tell_state: None,
|
||||
seed: 42,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_and_receive() {
|
||||
let queue = VoiceQueue::new();
|
||||
assert!(queue.submit(make_request(Priority::High, "Test")));
|
||||
assert_eq!(queue.pending_count(), 1);
|
||||
|
||||
let req = queue.receiver().try_recv().unwrap();
|
||||
assert_eq!(req.base_text, "Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backpressure_drops_when_full() {
|
||||
let (sender, _receiver) = crossbeam_channel::bounded(2);
|
||||
// Fill the channel
|
||||
sender.try_send(make_request(Priority::High, "A")).unwrap();
|
||||
sender.try_send(make_request(Priority::High, "B")).unwrap();
|
||||
// Third should fail
|
||||
assert!(sender.try_send(make_request(Priority::High, "C")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_drain_orders_correctly() {
|
||||
let queue = VoiceQueue::new();
|
||||
queue.submit(make_request(Priority::Background, "low"));
|
||||
queue.submit(make_request(Priority::Critical, "high"));
|
||||
queue.submit(make_request(Priority::Standard, "mid"));
|
||||
|
||||
let mut drain = PriorityDrain::from_receiver(&queue.receiver());
|
||||
assert_eq!(drain.len(), 3);
|
||||
|
||||
let first = drain.pop().unwrap();
|
||||
assert_eq!(first.base_text, "high");
|
||||
assert_eq!(first.priority, Priority::Critical);
|
||||
|
||||
let second = drain.pop().unwrap();
|
||||
assert_eq!(second.base_text, "mid");
|
||||
|
||||
let third = drain.pop().unwrap();
|
||||
assert_eq!(third.base_text, "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_resume() {
|
||||
let queue = VoiceQueue::new();
|
||||
assert!(!queue.is_paused());
|
||||
queue.pause();
|
||||
assert!(queue.is_paused());
|
||||
queue.resume();
|
||||
assert!(!queue.is_paused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reprioritize_reshuffles_on_zone_change() {
|
||||
let queue = VoiceQueue::new();
|
||||
|
||||
// Zone 100 is current, zone 200 is adjacent
|
||||
let mut req_a = make_request(Priority::High, "current zone NPC");
|
||||
req_a.zone_id = 100;
|
||||
let mut req_b = make_request(Priority::Standard, "adjacent zone NPC");
|
||||
req_b.zone_id = 200;
|
||||
|
||||
queue.submit(req_a);
|
||||
queue.submit(req_b);
|
||||
assert_eq!(queue.pending_count(), 2);
|
||||
|
||||
// Player moves to zone 200 — reprioritize
|
||||
queue.pause();
|
||||
queue.reprioritize(|req| {
|
||||
if req.zone_id == 200 {
|
||||
Priority::High // was adjacent, now current
|
||||
} else {
|
||||
Priority::Background // was current, now distant
|
||||
}
|
||||
});
|
||||
queue.resume();
|
||||
|
||||
// Drain with priority ordering — zone 200 should come first
|
||||
let mut drain = PriorityDrain::from_receiver(&queue.receiver());
|
||||
let first = drain.pop().unwrap();
|
||||
assert_eq!(first.zone_id, 200);
|
||||
assert_eq!(first.priority, Priority::High);
|
||||
|
||||
let second = drain.pop().unwrap();
|
||||
assert_eq!(second.zone_id, 100);
|
||||
assert_eq!(second.priority, Priority::Background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! 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.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
|
||||
use crate::npc::blueprint::CultureProfile;
|
||||
use crate::voice::cache::{CacheKey, VoiceCacheStore};
|
||||
use crate::voice::prompt_builder;
|
||||
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>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
active_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
struct WorkerHandle {
|
||||
thread: Option<JoinHandle<()>>,
|
||||
id: usize,
|
||||
}
|
||||
|
||||
/// Shared state passed to each worker thread.
|
||||
pub struct WorkerContext {
|
||||
pub receiver: Receiver<VoiceRequest>,
|
||||
pub cache: Arc<Mutex<VoiceCacheStore>>,
|
||||
pub cultures: Arc<HashMap<String, CultureProfile>>,
|
||||
pub shutdown: Arc<AtomicBool>,
|
||||
pub active_count: Arc<AtomicUsize>,
|
||||
pub paused: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl WorkerPool {
|
||||
/// Spawn `count` worker threads, each connecting to sr-voice on
|
||||
/// `base_port + worker_id`.
|
||||
pub fn spawn(
|
||||
count: usize,
|
||||
base_port: u16,
|
||||
receiver: Receiver<VoiceRequest>,
|
||||
cache: Arc<Mutex<VoiceCacheStore>>,
|
||||
cultures: Arc<HashMap<String, CultureProfile>>,
|
||||
) -> Self {
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let active_count = Arc::new(AtomicUsize::new(0));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut workers = Vec::with_capacity(count);
|
||||
|
||||
for id in 0..count {
|
||||
let ctx = WorkerContext {
|
||||
receiver: receiver.clone(),
|
||||
cache: Arc::clone(&cache),
|
||||
cultures: Arc::clone(&cultures),
|
||||
shutdown: Arc::clone(&shutdown),
|
||||
active_count: Arc::clone(&active_count),
|
||||
paused: Arc::clone(&paused),
|
||||
};
|
||||
let port = base_port + id as u16;
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name(format!("voice-worker-{}", id))
|
||||
.spawn(move || worker_loop(id, port, ctx))
|
||||
.expect("failed to spawn voice worker thread");
|
||||
|
||||
workers.push(WorkerHandle {
|
||||
thread: Some(thread),
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(count, base_port, "voice worker pool started");
|
||||
|
||||
Self {
|
||||
workers,
|
||||
shutdown,
|
||||
active_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of workers currently processing a request.
|
||||
pub fn active_workers(&self) -> usize {
|
||||
self.active_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Total number of worker threads.
|
||||
pub fn worker_count(&self) -> usize {
|
||||
self.workers.len()
|
||||
}
|
||||
|
||||
/// Signal all workers to shut down and join their threads.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
for handle in &mut self.workers {
|
||||
if let Some(thread) = handle.thread.take() {
|
||||
let _ = thread.join();
|
||||
tracing::debug!(id = handle.id, "voice worker joined");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkerPool {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
// Below-normal thread priority is handled at the OS level by the
|
||||
// sr-voice process itself (nice value). Worker threads inherit it.
|
||||
|
||||
loop {
|
||||
if ctx.shutdown.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for a request (with timeout so we can check shutdown)
|
||||
let request = match ctx.receiver.recv_timeout(Duration::from_secs(1)) {
|
||||
Ok(req) => req,
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
|
||||
};
|
||||
|
||||
// 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);
|
||||
ctx.active_count.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
tracing::debug!(id, "voice worker stopped");
|
||||
}
|
||||
|
||||
/// Process a single voice request: build prompt → infer → validate → cache.
|
||||
fn process_request(
|
||||
worker_id: usize,
|
||||
base_url: &str,
|
||||
request: &VoiceRequest,
|
||||
ctx: &WorkerContext,
|
||||
) {
|
||||
let culture = match ctx.cultures.get(&request.culture_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
culture_id = %request.culture_id,
|
||||
"unknown culture — serving base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build prompt
|
||||
let built = prompt_builder::build_prompt(
|
||||
culture,
|
||||
&request.base_text,
|
||||
request.content_type,
|
||||
request.tell_state,
|
||||
request.seed,
|
||||
);
|
||||
|
||||
// Call sr-voice
|
||||
let result = call_sr_voice(base_url, &built.prompt);
|
||||
|
||||
match result {
|
||||
Ok(text) if text.split_whitespace().count() >= MIN_TOKENS => {
|
||||
cache_result(request, &text, ctx);
|
||||
}
|
||||
Ok(_short_text) => {
|
||||
// Empty output guard: retry once with different seed
|
||||
tracing::debug!(
|
||||
worker_id,
|
||||
npc = request.npc_stable_id,
|
||||
"short output — retrying with different seed"
|
||||
);
|
||||
let retry_built = prompt_builder::build_prompt(
|
||||
culture,
|
||||
&request.base_text,
|
||||
request.content_type,
|
||||
request.tell_state,
|
||||
request.seed.wrapping_add(1),
|
||||
);
|
||||
match call_sr_voice(base_url, &retry_built.prompt) {
|
||||
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,
|
||||
"retry also short — caching base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
worker_id,
|
||||
error = %e,
|
||||
"sr-voice request failed — serving base text"
|
||||
);
|
||||
cache_base_text(request, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if let Ok(mut cache) = ctx.cache.lock() {
|
||||
cache.store(request.zone_id, key, text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache the base text as fallback (graceful degradation).
|
||||
fn cache_base_text(request: &VoiceRequest, ctx: &WorkerContext) {
|
||||
let key = cache_key_from_request(request);
|
||||
if let Ok(mut cache) = ctx.cache.lock() {
|
||||
cache.store(request.zone_id, key, request.base_text.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_key_from_request(request: &VoiceRequest) -> CacheKey {
|
||||
CacheKey {
|
||||
culture_id: request.culture_id.clone(),
|
||||
npc_stable_id: request.npc_stable_id,
|
||||
content_type: request.content_type,
|
||||
content_index: request.content_index,
|
||||
tell_state: request.tell_state,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cache_key_from_request_maps_fields() {
|
||||
let request = VoiceRequest {
|
||||
priority: crate::voice::queue::Priority::High,
|
||||
npc_stable_id: 42,
|
||||
zone_id: 100,
|
||||
culture_id: "krenn".into(),
|
||||
base_text: "Test.".into(),
|
||||
content_type: crate::voice::prompt_builder::ContentType::Dialogue,
|
||||
content_index: 5,
|
||||
tell_state: Some(crate::npc::tell_state::TellCategory::Angry),
|
||||
seed: 99,
|
||||
};
|
||||
let key = cache_key_from_request(&request);
|
||||
assert_eq!(key.culture_id, "krenn");
|
||||
assert_eq!(key.npc_stable_id, 42);
|
||||
assert_eq!(key.content_index, 5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user