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