Built-in index and pool resolver, and integrate resolution with speaker persistence and the TUI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user