Add sample config and the option to speak repeated lines at a faster rate
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user