diff --git a/README.md b/README.md index eff91a6..2c8ea40 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ speaker mappings, generated audio, and TTS endpoint stay on your computer. refreshes its selectable voice catalog from `GET /v1/audio/voices`. - Sends non-streaming WAV requests to `POST /v1/audio/speech` and plays clips sequentially with native audio support. +- Repeats an NPC's identical line at 1.5x speed, then 2x speed when each repeat + arrives within 15 seconds; a later line returns to normal speed. - Picks a real voice from your Kokoro server for a newly seen NPC and saves it. - Lets you pin an NPC to a particular voice, reroll a persistent random voice, or set the NPC to get a new random voice for every line. @@ -45,6 +47,7 @@ On NixOS, enter the included development shell. It provides the ALSA development metadata required by the native Linux audio backend: ```bash +doas systemctl start podman-kokoro-fastapi.service nix-shell --run 'cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"' ``` @@ -99,6 +102,14 @@ standard application-data directory: On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform directory is used on macOS and Windows. +The repeat behavior can be adjusted in `config.toml`: + +```toml +repeat_window_seconds = 15 +repeat_second_speed = 1.5 +repeat_third_plus_speed = 2.0 +``` + ## Development ```bash diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0c6870c --- /dev/null +++ b/config.toml @@ -0,0 +1,20 @@ +# Copy or adapt this file to Mudmouth's application-data directory: +# ~/.local/share/mudmouth/config.toml on Linux. + +kokoro_url = "http://127.0.0.1:8880" + +# Set this to your EverQuest log, or configure it through the TUI. +# log_path = "/home/you/Games/EverQuestLegends/Logs/eqlog_Character_server.txt" + +volume = 0.85 +max_queue_length = 10 + +# An NPC repeating the same line within this window accelerates it: +# first occurrence: 1x, second: repeat_second_speed, third and later: repeat_third_plus_speed. +repeat_window_seconds = 15 +repeat_second_speed = 1.5 +repeat_third_plus_speed = 2.0 + +[events] +npc_dialogue = true +zone_transitions = false diff --git a/src/config.rs b/src/config.rs index 90cf175..063d358 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,7 +14,9 @@ pub struct Config { pub log_path: Option, pub volume: f32, pub max_queue_length: usize, - pub dedup_window_seconds: u64, + pub repeat_window_seconds: u64, + pub repeat_second_speed: f32, + pub repeat_third_plus_speed: f32, pub events: EventSettings, } @@ -32,7 +34,9 @@ impl Default for Config { log_path: None, volume: 0.85, max_queue_length: 10, - dedup_window_seconds: 5, + repeat_window_seconds: 15, + repeat_second_speed: 1.5, + repeat_third_plus_speed: 2.0, events: EventSettings::default(), } } diff --git a/src/kokoro.rs b/src/kokoro.rs index 5b8b973..6c47188 100644 --- a/src/kokoro.rs +++ b/src/kokoro.rs @@ -37,6 +37,7 @@ struct SpeechRequest<'a> { voice: &'a str, model: &'static str, response_format: &'static str, + speed: f32, stream: bool, } @@ -82,7 +83,7 @@ impl KokoroClient { .collect()) } - pub async fn synthesize_wav(&self, text: &str, voice: &str) -> Result> { + pub async fn synthesize_wav(&self, text: &str, voice: &str, speed: f32) -> Result> { let response = self .client .post(self.endpoint("v1/audio/speech")?) @@ -91,6 +92,7 @@ impl KokoroClient { voice, model: "kokoro", response_format: "wav", + speed, stream: false, }) .send() @@ -156,12 +158,25 @@ mod tests { let url = serve_once("200 OK", "audio/wav", b"RIFFmock"); let wav = KokoroClient::new(&url) .unwrap() - .synthesize_wav("hello", "af_heart") + .synthesize_wav("hello", "af_heart", 1.0) .await .unwrap(); assert_eq!(wav, b"RIFFmock"); } + #[test] + fn speech_request_includes_speed() { + let request = SpeechRequest { + input: "hello", + voice: "af_heart", + model: "kokoro", + response_format: "wav", + speed: 1.5, + stream: false, + }; + assert_eq!(serde_json::to_value(request).unwrap()["speed"], 1.5); + } + #[tokio::test] async fn treats_non_success_as_failure() { let url = serve_once("503 Service Unavailable", "application/json", b"{}"); diff --git a/src/main.rs b/src/main.rs index 83ee00a..8b29ea9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, mpsc, }, - time::Duration, + time::{Duration, Instant}, }; use tokio::sync::mpsc as tokio_mpsc; use tui::{App, UiAction}; @@ -164,7 +164,7 @@ fn process_worker_event( } else { let key = events::speaker_key(speaker); if let Some(voice) = app.speakers.resolve_voice( - key, + key.clone(), speaker.clone(), &app.voices, app.current_zone.clone(), @@ -174,6 +174,9 @@ fn process_worker_event( .try_send(SynthesisJob { text: text.clone(), voice, + speed: 1.0, + repeat_key: format!("{key}\u{1f}{text}"), + observed_at: Instant::now(), }) .is_err() { @@ -197,7 +200,13 @@ fn enqueue_random_event( use rand::prelude::IndexedRandom; if let Some(voice) = app.voices.choose(&mut rand::rng()).cloned() { synthesis_sender - .try_send(SynthesisJob { text, voice }) + .try_send(SynthesisJob { + repeat_key: format!("event:\u{1f}{text}"), + text, + voice, + speed: 1.0, + observed_at: Instant::now(), + }) .context("speech queue is busy")?; } Ok(()) diff --git a/src/workers.rs b/src/workers.rs index 70190c4..3196e9f 100644 --- a/src/workers.rs +++ b/src/workers.rs @@ -40,6 +40,15 @@ pub enum KokoroStatus { pub struct SynthesisJob { pub text: String, pub voice: String, + pub speed: f32, + pub repeat_key: String, + pub observed_at: Instant, +} + +#[derive(Debug)] +struct RepeatState { + last_seen: Instant, + count: usize, } #[derive(Debug)] @@ -145,7 +154,7 @@ pub fn spawn_kokoro_worker( }; let mut voices = Vec::new(); let mut last_health_check = Instant::now() - Duration::from_secs(30); - let mut recent = HashMap::::new(); + let mut repeats = HashMap::::new(); let mut queued = VecDeque::new(); loop { @@ -196,26 +205,24 @@ pub fn spawn_kokoro_worker( } while let Ok(job) = jobs.try_recv() { - let key = format!("{}:{}", job.voice, job.text); - let is_duplicate = recent.get(&key).is_some_and(|last| { - last.elapsed() < Duration::from_secs(config.dedup_window_seconds) - }); - if is_duplicate { - let _ = - event_sender.send(WorkerEvent::Activity("duplicate line skipped".into())); - } else if queued.len() >= config.max_queue_length { + if queued.len() >= config.max_queue_length { let _ = event_sender.send(WorkerEvent::Activity( "speech queue full; line dropped".into(), )); } else { - recent.insert(key, Instant::now()); + let mut job = job; + job.speed = + repeat_speed(&mut repeats, &job.repeat_key, &config, job.observed_at); queued.push_back(job); } } if let Some(job) = queued.pop_front() { let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len() + 1)); - match client.synthesize_wav(&job.text, &job.voice).await { + match client + .synthesize_wav(&job.text, &job.voice, job.speed) + .await + { Ok(wav) => { if audio_sender.send(AudioCommand::Play(wav)).is_err() { let _ = event_sender.send(WorkerEvent::Activity( @@ -238,6 +245,30 @@ pub fn spawn_kokoro_worker( control_sender } +fn repeat_speed( + repeats: &mut HashMap, + key: &str, + config: &Config, + now: Instant, +) -> f32 { + let window = Duration::from_secs(config.repeat_window_seconds); + let state = repeats.entry(key.to_owned()).or_insert(RepeatState { + last_seen: now, + count: 0, + }); + if now.duration_since(state.last_seen) >= window { + state.count = 0; + } + state.last_seen = now; + state.count += 1; + + match state.count { + 1 => 1.0, + 2 => config.repeat_second_speed.clamp(0.25, 4.0), + _ => config.repeat_third_plus_speed.clamp(0.25, 4.0), + } +} + #[cfg(test)] mod tests { use super::*; @@ -256,4 +287,43 @@ mod tests { fs::write(&path, "fresh\n").unwrap(); assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["fresh"]); } + + #[test] + fn repeated_dialogue_speeds_up_then_resets() { + let config = Config::default(); + let now = Instant::now(); + let mut repeats = HashMap::new(); + + assert_eq!( + repeat_speed(&mut repeats, "npc:turga\u{1f}hello", &config, now), + 1.0 + ); + assert_eq!( + repeat_speed( + &mut repeats, + "npc:turga\u{1f}hello", + &config, + now + Duration::from_secs(1), + ), + 1.5 + ); + assert_eq!( + repeat_speed( + &mut repeats, + "npc:turga\u{1f}hello", + &config, + now + Duration::from_secs(2), + ), + 2.0 + ); + assert_eq!( + repeat_speed( + &mut repeats, + "npc:turga\u{1f}hello", + &config, + now + Duration::from_secs(18), + ), + 1.0 + ); + } }