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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user