Add sample config and the option to speak repeated lines at a faster rate
CI / macos-latest (push) Has been cancelled
CI / ubuntu-latest (push) Has been cancelled
CI / windows-latest (push) Has been cancelled

This commit is contained in:
pleb
2026-07-21 09:33:47 -07:00
parent 54fa53222e
commit 456893415d
6 changed files with 147 additions and 18 deletions
+11
View File
@@ -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`. refreshes its selectable voice catalog from `GET /v1/audio/voices`.
- Sends non-streaming WAV requests to `POST /v1/audio/speech` and plays clips - Sends non-streaming WAV requests to `POST /v1/audio/speech` and plays clips
sequentially with native audio support. 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. - 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, - 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. 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: development metadata required by the native Linux audio backend:
```bash ```bash
doas systemctl start podman-kokoro-fastapi.service
nix-shell --run 'cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"' 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 On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform
directory is used on macOS and Windows. 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 ## Development
```bash ```bash
+20
View File
@@ -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
+6 -2
View File
@@ -14,7 +14,9 @@ pub struct Config {
pub log_path: Option<PathBuf>, pub log_path: Option<PathBuf>,
pub volume: f32, pub volume: f32,
pub max_queue_length: usize, 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, pub events: EventSettings,
} }
@@ -32,7 +34,9 @@ impl Default for Config {
log_path: None, log_path: None,
volume: 0.85, volume: 0.85,
max_queue_length: 10, 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(), events: EventSettings::default(),
} }
} }
+17 -2
View File
@@ -37,6 +37,7 @@ struct SpeechRequest<'a> {
voice: &'a str, voice: &'a str,
model: &'static str, model: &'static str,
response_format: &'static str, response_format: &'static str,
speed: f32,
stream: bool, stream: bool,
} }
@@ -82,7 +83,7 @@ impl KokoroClient {
.collect()) .collect())
} }
pub async fn synthesize_wav(&self, text: &str, voice: &str) -> Result<Vec<u8>> { pub async fn synthesize_wav(&self, text: &str, voice: &str, speed: f32) -> Result<Vec<u8>> {
let response = self let response = self
.client .client
.post(self.endpoint("v1/audio/speech")?) .post(self.endpoint("v1/audio/speech")?)
@@ -91,6 +92,7 @@ impl KokoroClient {
voice, voice,
model: "kokoro", model: "kokoro",
response_format: "wav", response_format: "wav",
speed,
stream: false, stream: false,
}) })
.send() .send()
@@ -156,12 +158,25 @@ mod tests {
let url = serve_once("200 OK", "audio/wav", b"RIFFmock"); let url = serve_once("200 OK", "audio/wav", b"RIFFmock");
let wav = KokoroClient::new(&url) let wav = KokoroClient::new(&url)
.unwrap() .unwrap()
.synthesize_wav("hello", "af_heart") .synthesize_wav("hello", "af_heart", 1.0)
.await .await
.unwrap(); .unwrap();
assert_eq!(wav, b"RIFFmock"); 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] #[tokio::test]
async fn treats_non_success_as_failure() { async fn treats_non_success_as_failure() {
let url = serve_once("503 Service Unavailable", "application/json", b"{}"); let url = serve_once("503 Service Unavailable", "application/json", b"{}");
+12 -3
View File
@@ -23,7 +23,7 @@ use std::{
atomic::{AtomicUsize, Ordering}, atomic::{AtomicUsize, Ordering},
mpsc, mpsc,
}, },
time::Duration, time::{Duration, Instant},
}; };
use tokio::sync::mpsc as tokio_mpsc; use tokio::sync::mpsc as tokio_mpsc;
use tui::{App, UiAction}; use tui::{App, UiAction};
@@ -164,7 +164,7 @@ fn process_worker_event(
} else { } else {
let key = events::speaker_key(speaker); let key = events::speaker_key(speaker);
if let Some(voice) = app.speakers.resolve_voice( if let Some(voice) = app.speakers.resolve_voice(
key, key.clone(),
speaker.clone(), speaker.clone(),
&app.voices, &app.voices,
app.current_zone.clone(), app.current_zone.clone(),
@@ -174,6 +174,9 @@ fn process_worker_event(
.try_send(SynthesisJob { .try_send(SynthesisJob {
text: text.clone(), text: text.clone(),
voice, voice,
speed: 1.0,
repeat_key: format!("{key}\u{1f}{text}"),
observed_at: Instant::now(),
}) })
.is_err() .is_err()
{ {
@@ -197,7 +200,13 @@ fn enqueue_random_event(
use rand::prelude::IndexedRandom; use rand::prelude::IndexedRandom;
if let Some(voice) = app.voices.choose(&mut rand::rng()).cloned() { if let Some(voice) = app.voices.choose(&mut rand::rng()).cloned() {
synthesis_sender 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")?; .context("speech queue is busy")?;
} }
Ok(()) Ok(())
+81 -11
View File
@@ -40,6 +40,15 @@ pub enum KokoroStatus {
pub struct SynthesisJob { pub struct SynthesisJob {
pub text: String, pub text: String,
pub voice: 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)] #[derive(Debug)]
@@ -145,7 +154,7 @@ pub fn spawn_kokoro_worker(
}; };
let mut voices = Vec::new(); let mut voices = Vec::new();
let mut last_health_check = Instant::now() - Duration::from_secs(30); let mut last_health_check = Instant::now() - Duration::from_secs(30);
let mut recent = HashMap::<String, Instant>::new(); let mut repeats = HashMap::<String, RepeatState>::new();
let mut queued = VecDeque::new(); let mut queued = VecDeque::new();
loop { loop {
@@ -196,26 +205,24 @@ pub fn spawn_kokoro_worker(
} }
while let Ok(job) = jobs.try_recv() { while let Ok(job) = jobs.try_recv() {
let key = format!("{}:{}", job.voice, job.text); if queued.len() >= config.max_queue_length {
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 {
let _ = event_sender.send(WorkerEvent::Activity( let _ = event_sender.send(WorkerEvent::Activity(
"speech queue full; line dropped".into(), "speech queue full; line dropped".into(),
)); ));
} else { } 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); queued.push_back(job);
} }
} }
if let Some(job) = queued.pop_front() { if let Some(job) = queued.pop_front() {
let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len() + 1)); 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) => { Ok(wav) => {
if audio_sender.send(AudioCommand::Play(wav)).is_err() { if audio_sender.send(AudioCommand::Play(wav)).is_err() {
let _ = event_sender.send(WorkerEvent::Activity( let _ = event_sender.send(WorkerEvent::Activity(
@@ -238,6 +245,30 @@ pub fn spawn_kokoro_worker(
control_sender control_sender
} }
fn repeat_speed(
repeats: &mut HashMap<String, RepeatState>,
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -256,4 +287,43 @@ mod tests {
fs::write(&path, "fresh\n").unwrap(); fs::write(&path, "fresh\n").unwrap();
assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["fresh"]); 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
);
}
} }