Built-in index and pool resolver, and integrate resolution with speaker persistence and the TUI

This commit is contained in:
pleb
2026-07-29 10:56:38 -07:00
parent b4e622c79b
commit d1c83e8a7d
4 changed files with 426 additions and 31 deletions
+3
View File
@@ -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::<WorkerEvent>();
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
+235
View File
@@ -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<String>, npc: &str) -> Option<Self> {
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> {
Self::new(zone?, npc)
}
}
/// A deserialized generated EQEmu lookup.
#[derive(Debug, Default)]
pub struct NpcVoiceIndex {
entries: BTreeMap<LookupIdentity, Gender>,
}
impl NpcVoiceIndex {
pub fn lookup(&self, identity: &LookupIdentity) -> Option<Gender> {
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<String> {
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<Self, serde_json::Error> {
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<GeneratedEntry>,
}
#[derive(Debug, Deserialize)]
struct GeneratedEntry(String, String, Gender);
static BUILTIN_INDEX: LazyLock<NpcVoiceIndex> = 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<String>,
feminine: Vec<String>,
masculine: Vec<String>,
}
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<Gender>) -> &[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<String> {
["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");
}
}
+182 -27
View File
@@ -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<String>,
index: &NpcVoiceIndex,
) -> Option<String> {
let now = Local::now();
let speaker = self.speakers.entry(key).or_insert_with(|| {
let voice = choose_voice(voices).unwrap_or_else(|| "af_heart".into());
Speaker {
name,
mode: VoiceMode::PersistentRandom { voice },
last_seen: now,
last_zone: zone.clone(),
}
});
if let Some(speaker) = self.speakers.get_mut(&key) {
speaker.last_seen = now;
speaker.last_zone = zone;
match &speaker.mode {
return match &speaker.mode {
VoiceMode::PersistentRandom { voice } | VoiceMode::Pinned { voice } => {
Some(voice.clone())
}
VoiceMode::RandomEachUtterance => choose_voice(voices),
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: voice.clone(),
},
last_seen: now,
last_zone: zone,
},
);
Some(voice)
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &Speaker)> {
@@ -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<String> {
let identity = LookupIdentity::from_observed(zone, name);
index.resolve_pool(identity.as_ref(), voices)
}
fn choose_voice(voices: &[String]) -> Option<String> {
voices.choose(&mut rand::rng()).cloned()
}
@@ -117,25 +150,147 @@ fn choose_voice(voices: &[String]) -> Option<String> {
#[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<String> {
["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<String> {
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()),
);
}
}
+3 -1
View File
@@ -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);
}
}