From d1c83e8a7d4f8adeb13e41e55de9b3b941921c11 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 29 Jul 2026 10:56:38 -0700 Subject: [PATCH] Built-in index and pool resolver, and integrate resolution with speaker persistence and the TUI --- src/main.rs | 3 + src/npc_voices.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++ src/speakers.rs | 215 ++++++++++++++++++++++++++++++++++++------ src/tui.rs | 4 +- 4 files changed, 426 insertions(+), 31 deletions(-) create mode 100644 src/npc_voices.rs diff --git a/src/main.rs b/src/main.rs index 8bb37df..153da9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod config; mod eq_discovery; mod events; mod kokoro; +mod npc_voices; mod speakers; mod tui; mod workers; @@ -65,6 +66,7 @@ async fn main() -> Result<()> { config.kokoro_url = url; } + let _ = npc_voices::builtin_index(); let speakers = speakers::SpeakerStore::load()?; let (worker_sender, worker_receiver) = mpsc::channel::(); let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone()); @@ -166,6 +168,7 @@ fn process_worker_event( speaker.clone(), &app.voices, app.current_zone.clone(), + npc_voices::builtin_index(), ) { app.speakers.save()?; if synthesis_sender diff --git a/src/npc_voices.rs b/src/npc_voices.rs new file mode 100644 index 0000000..c41592e --- /dev/null +++ b/src/npc_voices.rs @@ -0,0 +1,235 @@ +//! Built-in EQEmu NPC gender lookup and Kokoro voice-pool resolution. +//! +//! This module intentionally only describes a suitable *pool* of voices. The +//! speaker store remains responsible for selecting and persisting an exact +//! voice. + +use crate::events::normalize_npc_name; +use serde::Deserialize; +use std::{collections::BTreeMap, sync::LazyLock}; + +const BUILTIN_INDEX_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/generated/peq-npc-gender-index.json" +)); + +/// The two gender traits that can narrow an NPC's initial voice pool. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Gender { + Masculine, + Feminine, +} + +/// A zone-qualified NPC identity used by the generated EQEmu lookup. +/// +/// `zone` is deliberately preserved exactly as it appears in the EverQuest +/// log. NPC names use the same normalization as speaker-store keys. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct LookupIdentity { + zone: String, + npc: String, +} + +impl LookupIdentity { + pub fn new(zone: impl Into, npc: &str) -> Option { + let zone = zone.into(); + let npc = normalize_npc_name(npc); + if zone.is_empty() || npc.is_empty() { + return None; + } + Some(Self { zone, npc }) + } + + pub fn from_observed(zone: Option<&str>, npc: &str) -> Option { + Self::new(zone?, npc) + } +} + +/// A deserialized generated EQEmu lookup. +#[derive(Debug, Default)] +pub struct NpcVoiceIndex { + entries: BTreeMap, +} + +impl NpcVoiceIndex { + pub fn lookup(&self, identity: &LookupIdentity) -> Option { + self.entries.get(identity).copied() + } + + /// Resolves the appropriate live voice pool for an NPC. + /// + /// An unknown identity, missing zone, or empty gender-specific pool all + /// deliberately fall back to the complete live curated catalog. + pub fn resolve_pool( + &self, + identity: Option<&LookupIdentity>, + live_curated_catalog: &[String], + ) -> Vec { + VoicePools::from_live_curated_catalog(live_curated_catalog) + .for_gender(identity.and_then(|identity| self.lookup(identity))) + .to_vec() + } + + fn from_bytes(bytes: &[u8]) -> Result { + let generated: GeneratedIndex = serde_json::from_slice(bytes)?; + assert_eq!( + generated.schema_version, 1, + "unsupported bundled PEQ index schema" + ); + + let entries = generated + .entries + .into_iter() + .map(|GeneratedEntry(zone, npc, gender)| { + ( + LookupIdentity::new(zone, &npc) + .expect("bundled PEQ index contains an empty lookup identity"), + gender, + ) + }) + .collect(); + Ok(Self { entries }) + } +} + +#[derive(Debug, Deserialize)] +struct GeneratedIndex { + schema_version: u8, + entries: Vec, +} + +#[derive(Debug, Deserialize)] +struct GeneratedEntry(String, String, Gender); + +static BUILTIN_INDEX: LazyLock = LazyLock::new(|| { + NpcVoiceIndex::from_bytes(BUILTIN_INDEX_BYTES) + .expect("bundled PEQ index must be valid generated JSON") +}); + +/// Returns the process-wide, once-deserialized built-in EQEmu lookup. +pub fn builtin_index() -> &'static NpcVoiceIndex { + &BUILTIN_INDEX +} + +/// Separates the already-curated live Kokoro catalog into usable pools. +/// +/// The Kokoro client owns the curated allowlist. Keeping it out of this module +/// ensures this resolver cannot drift from the catalog shown to the user. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VoicePools { + full: Vec, + feminine: Vec, + masculine: Vec, +} + +impl VoicePools { + pub fn from_live_curated_catalog(live_curated_catalog: &[String]) -> Self { + let mut pools = Self { + full: live_curated_catalog.to_vec(), + ..Self::default() + }; + for voice in live_curated_catalog { + if voice.starts_with("af_") || voice.starts_with("bf_") { + pools.feminine.push(voice.clone()); + } else if voice.starts_with("am_") || voice.starts_with("bm_") { + pools.masculine.push(voice.clone()); + } + } + pools + } + + pub fn for_gender(&self, gender: Option) -> &[String] { + match gender { + Some(Gender::Feminine) if !self.feminine.is_empty() => &self.feminine, + Some(Gender::Masculine) if !self.masculine.is_empty() => &self.masculine, + _ => &self.full, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_index(entries: &[(&str, &str, Gender)]) -> NpcVoiceIndex { + NpcVoiceIndex { + entries: entries + .iter() + .map(|(zone, npc, gender)| (LookupIdentity::new(*zone, npc).unwrap(), *gender)) + .collect(), + } + } + + fn catalog() -> Vec { + ["af_heart", "bf_alice", "am_adam", "bm_george"] + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn feminine_and_masculine_npcs_use_matching_pools() { + let index = test_index(&[ + ("Test Zone", "Lady Ada", Gender::Feminine), + ("Test Zone", "Captain Bob", Gender::Masculine), + ]); + let voices = catalog(); + + let lady = LookupIdentity::new("Test Zone", "Lady Ada").unwrap(); + assert_eq!( + index.resolve_pool(Some(&lady), &voices), + vec!["af_heart", "bf_alice"] + ); + + let captain = LookupIdentity::new("Test Zone", "Captain Bob").unwrap(); + assert_eq!( + index.resolve_pool(Some(&captain), &voices), + vec!["am_adam", "bm_george"] + ); + } + + #[test] + fn omitted_neuter_and_conflicting_entries_use_the_full_pool() { + let index = test_index(&[]); + let voices = catalog(); + + for npc in ["Neuter Construct", "Conflicting Guard"] { + let identity = LookupIdentity::new("Test Zone", npc).unwrap(); + assert_eq!(index.resolve_pool(Some(&identity), &voices), voices); + } + } + + #[test] + fn missing_zone_and_unknown_npc_use_the_full_pool() { + let index = test_index(&[("Test Zone", "Lady Ada", Gender::Feminine)]); + let voices = catalog(); + + assert_eq!(index.resolve_pool(None, &voices), voices); + let unknown = LookupIdentity::new("Test Zone", "Unknown NPC").unwrap(); + assert_eq!(index.resolve_pool(Some(&unknown), &voices), voices); + assert!(LookupIdentity::from_observed(None, "Lady Ada").is_none()); + } + + #[test] + fn an_empty_gender_pool_falls_back_to_the_full_pool() { + let index = test_index(&[("Test Zone", "Lady Ada", Gender::Feminine)]); + let voices = vec!["am_adam".into()]; + let lady = LookupIdentity::new("Test Zone", "Lady Ada").unwrap(); + + assert_eq!(index.resolve_pool(Some(&lady), &voices), voices); + } + + #[test] + fn embedded_index_is_available_for_known_npcs() { + let identity = LookupIdentity::new("West Commonlands", "Bealya Tanilsuia").unwrap(); + assert_eq!(builtin_index().lookup(&identity), Some(Gender::Feminine)); + } + + #[test] + fn identity_normalizes_npc_names_but_preserves_display_zone() { + let identity = LookupIdentity::new("A Zone", "Zok Caropni!").unwrap(); + assert_eq!(identity.zone, "A Zone"); + assert_eq!(identity.npc, "zok_caropni"); + } +} diff --git a/src/speakers.rs b/src/speakers.rs index ca22e7d..251823f 100644 --- a/src/speakers.rs +++ b/src/speakers.rs @@ -1,4 +1,7 @@ -use crate::config::{Config, atomic_write}; +use crate::{ + config::{Config, atomic_write}, + npc_voices::{LookupIdentity, NpcVoiceIndex}, +}; use anyhow::{Context, Result}; use chrono::{DateTime, Local}; use rand::prelude::IndexedRandom; @@ -61,26 +64,39 @@ impl SpeakerStore { name: String, voices: &[String], zone: Option, + index: &NpcVoiceIndex, ) -> Option { let now = Local::now(); - let speaker = self.speakers.entry(key).or_insert_with(|| { - let voice = choose_voice(voices).unwrap_or_else(|| "af_heart".into()); + if let Some(speaker) = self.speakers.get_mut(&key) { + speaker.last_seen = now; + speaker.last_zone = zone; + return match &speaker.mode { + VoiceMode::PersistentRandom { voice } | VoiceMode::Pinned { voice } => { + Some(voice.clone()) + } + VoiceMode::RandomEachUtterance => choose_voice(&resolved_pool( + index, + &name, + speaker.last_zone.as_deref(), + voices, + )), + }; + } + + let voice = choose_voice(&resolved_pool(index, &name, zone.as_deref(), voices)) + .unwrap_or_else(|| "af_heart".into()); + self.speakers.insert( + key, Speaker { name, - mode: VoiceMode::PersistentRandom { voice }, + mode: VoiceMode::PersistentRandom { + voice: voice.clone(), + }, last_seen: now, - last_zone: zone.clone(), - } - }); - - speaker.last_seen = now; - speaker.last_zone = zone; - match &speaker.mode { - VoiceMode::PersistentRandom { voice } | VoiceMode::Pinned { voice } => { - Some(voice.clone()) - } - VoiceMode::RandomEachUtterance => choose_voice(voices), - } + last_zone: zone, + }, + ); + Some(voice) } pub fn entries(&self) -> impl Iterator { @@ -103,13 +119,30 @@ impl SpeakerStore { } } - pub fn reset_persistent_random(&mut self, key: &str, voices: &[String]) { - if let (Some(speaker), Some(voice)) = (self.speakers.get_mut(key), choose_voice(voices)) { + pub fn reset_persistent_random(&mut self, key: &str, voices: &[String], index: &NpcVoiceIndex) { + if let Some(speaker) = self.speakers.get_mut(key) + && let Some(voice) = choose_voice(&resolved_pool( + index, + &speaker.name, + speaker.last_zone.as_deref(), + voices, + )) + { speaker.mode = VoiceMode::PersistentRandom { voice }; } } } +fn resolved_pool( + index: &NpcVoiceIndex, + name: &str, + zone: Option<&str>, + voices: &[String], +) -> Vec { + let identity = LookupIdentity::from_observed(zone, name); + index.resolve_pool(identity.as_ref(), voices) +} + fn choose_voice(voices: &[String]) -> Option { voices.choose(&mut rand::rng()).cloned() } @@ -117,25 +150,147 @@ fn choose_voice(voices: &[String]) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::npc_voices::builtin_index; - #[test] - fn random_assignment_is_persisted() { - let mut store = SpeakerStore::default(); - let voices = vec!["af_heart".into(), "am_adam".into()]; - let first = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); - let next = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); - assert_eq!(first, next); + const FEMALE_NPC: &str = "Bealya Tanilsuia"; + const FEMALE_ZONE: &str = "West Commonlands"; + + fn catalog() -> Vec { + ["af_heart", "bf_alice", "am_adam", "bm_george"] + .into_iter() + .map(str::to_owned) + .collect() + } + + fn resolve( + store: &mut SpeakerStore, + key: &str, + name: &str, + voices: &[String], + zone: Option<&str>, + ) -> Option { + store.resolve_voice( + key.into(), + name.into(), + voices, + zone.map(str::to_owned), + builtin_index(), + ) + } + + fn is_feminine(voice: &str) -> bool { + voice.starts_with("af_") || voice.starts_with("bf_") } #[test] - fn random_each_utterance_remains_configurable() { + fn saved_persistent_and_pinned_voices_survive_catalog_refreshes_and_restarts() { let mut store = SpeakerStore::default(); - let voices = vec!["af_heart".into()]; - store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None); - store.set_random_each_utterance("npc:turga"); + let voices = catalog(); + let persistent = resolve( + &mut store, + "npc:bealya_tanilsuia", + FEMALE_NPC, + &voices, + Some(FEMALE_ZONE), + ) + .unwrap(); + assert!(is_feminine(&persistent)); + + store.pin("npc:bealya_tanilsuia", "am_adam".into()); + let serialized = serde_json::to_string(&store).unwrap(); + let mut reloaded: SpeakerStore = serde_json::from_str(&serialized).unwrap(); + let refreshed_catalog = vec!["af_heart".into()]; + assert_eq!( - store.get("npc:turga").unwrap().mode, + resolve( + &mut reloaded, + "npc:bealya_tanilsuia", + FEMALE_NPC, + &refreshed_catalog, + Some(FEMALE_ZONE), + ), + Some("am_adam".into()), + ); + + let mut persistent_store = SpeakerStore::default(); + persistent_store.speakers.insert( + "npc:bealya_tanilsuia".into(), + Speaker { + name: FEMALE_NPC.into(), + mode: VoiceMode::PersistentRandom { + voice: persistent.clone(), + }, + last_seen: Local::now(), + last_zone: Some(FEMALE_ZONE.into()), + }, + ); + let serialized = serde_json::to_string(&persistent_store).unwrap(); + let mut reloaded: SpeakerStore = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + resolve( + &mut reloaded, + "npc:bealya_tanilsuia", + FEMALE_NPC, + &refreshed_catalog, + Some(FEMALE_ZONE), + ), + Some(persistent), + ); + } + + #[test] + fn manual_reroll_and_random_each_utterance_use_the_resolved_pool() { + let mut store = SpeakerStore::default(); + let voices = catalog(); + resolve( + &mut store, + "npc:bealya_tanilsuia", + FEMALE_NPC, + &voices, + Some(FEMALE_ZONE), + ); + store.reset_persistent_random("npc:bealya_tanilsuia", &voices, builtin_index()); + assert!(matches!( + &store.get("npc:bealya_tanilsuia").unwrap().mode, + VoiceMode::PersistentRandom { voice } if is_feminine(voice) + )); + + store.set_random_each_utterance("npc:bealya_tanilsuia"); + assert_eq!( + store.get("npc:bealya_tanilsuia").unwrap().mode, VoiceMode::RandomEachUtterance ); + for _ in 0..8 { + assert!(is_feminine( + &resolve( + &mut store, + "npc:bealya_tanilsuia", + FEMALE_NPC, + &voices, + Some(FEMALE_ZONE), + ) + .unwrap() + )); + } + } + + #[test] + fn legacy_speaker_json_loads_without_migration() { + let legacy = r#"{ + "speakers": { + "npc:turga": { + "name": "Turga", + "mode": {"mode": "pinned", "voice": "am_adam"}, + "last_seen": "2026-07-17T20:10:29+00:00", + "last_zone": null + } + } + }"#; + let mut store: SpeakerStore = serde_json::from_str(legacy).unwrap(); + + assert_eq!( + resolve(&mut store, "npc:turga", "Turga", &["af_heart".into()], None,), + Some("am_adam".into()), + ); } } diff --git a/src/tui.rs b/src/tui.rs index dcbbe52..7a1e39c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,7 @@ use crate::{ config::Config, eq_discovery::{CharacterLog, discover_character_logs}, + npc_voices::builtin_index, speakers::SpeakerStore, workers::{KokoroStatus, WorkerEvent}, }; @@ -344,7 +345,8 @@ impl App { } KeyCode::Char('x') => { if let Some(key) = keys.get(self.selected_speaker) { - self.speakers.reset_persistent_random(key, &self.voices); + self.speakers + .reset_persistent_random(key, &self.voices, builtin_index()); return Some(UiAction::ConfigChanged); } }