Files
mudmouth/src/workers.rs
T
pleb 456893415d
CI / macos-latest (push) Has been cancelled
CI / ubuntu-latest (push) Has been cancelled
CI / windows-latest (push) Has been cancelled
Add sample config and the option to speak repeated lines at a faster rate
2026-07-21 09:33:47 -07:00

330 lines
11 KiB
Rust

use crate::{
config::Config,
events::{LogEvent, parse_line},
kokoro::KokoroClient,
};
use anyhow::Result;
use rodio::{Decoder, OutputStreamBuilder, Sink};
use std::{
collections::{HashMap, VecDeque},
fs::File,
io::{BufRead, BufReader, Cursor, Seek, SeekFrom},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc::{self, Receiver, Sender},
},
thread,
time::{Duration, Instant},
};
use tokio::sync::mpsc as tokio_mpsc;
#[derive(Debug, Clone)]
pub enum WorkerEvent {
Log(LogEvent),
Activity(String),
KokoroStatus(KokoroStatus),
Voices(Vec<String>),
QueueDepth(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KokoroStatus {
Checking,
Connected,
Unreachable,
}
#[derive(Debug)]
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)]
pub enum AudioCommand {
Play(Vec<u8>),
SetVolume(f32),
}
pub fn spawn_log_tailer(
path: PathBuf,
sender: Sender<WorkerEvent>,
generation: Arc<AtomicUsize>,
expected_generation: usize,
) {
thread::spawn(move || {
let mut position = None;
while generation.load(Ordering::Relaxed) == expected_generation {
match read_new_lines(&path, &mut position) {
Ok(lines) => {
for line in lines {
if let Some(event) = parse_line(&line) {
let _ = sender.send(WorkerEvent::Log(event));
}
}
}
Err(error) => {
let _ = sender.send(WorkerEvent::Activity(format!("log watch: {error}")));
}
}
thread::sleep(Duration::from_millis(350));
}
});
}
fn read_new_lines(path: &Path, position: &mut Option<u64>) -> Result<Vec<String>> {
let mut file = File::open(path)?;
let length = file.metadata()?.len();
let offset = position.unwrap_or(length);
let offset = if length < offset { 0 } else { offset };
file.seek(SeekFrom::Start(offset))?;
let reader = BufReader::new(&file);
let lines = reader.lines().collect::<std::io::Result<Vec<_>>>()?;
*position = Some(length);
Ok(lines)
}
pub fn spawn_audio_worker(volume: f32, event_sender: Sender<WorkerEvent>) -> Sender<AudioCommand> {
let (sender, receiver): (Sender<AudioCommand>, Receiver<AudioCommand>) = mpsc::channel();
thread::spawn(move || {
let stream = match OutputStreamBuilder::open_default_stream() {
Ok(stream) => stream,
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"audio output unavailable: {error}"
)));
return;
}
};
let mut volume = volume;
while let Ok(command) = receiver.recv() {
let wav = match command {
AudioCommand::Play(wav) => wav,
AudioCommand::SetVolume(updated_volume) => {
volume = updated_volume;
continue;
}
};
let _ = event_sender.send(WorkerEvent::QueueDepth(1));
match Decoder::try_from(Cursor::new(wav)) {
Ok(source) => {
let sink = Sink::connect_new(stream.mixer());
sink.set_volume(volume);
sink.append(source);
sink.sleep_until_end();
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"audio decode failed: {error}"
)));
}
}
let _ = event_sender.send(WorkerEvent::QueueDepth(0));
}
});
sender
}
pub fn spawn_kokoro_worker(
config: Config,
mut jobs: tokio_mpsc::Receiver<SynthesisJob>,
audio_sender: Sender<AudioCommand>,
event_sender: Sender<WorkerEvent>,
) -> tokio_mpsc::Sender<Config> {
let (control_sender, mut control_receiver) = tokio_mpsc::channel::<Config>(4);
tokio::spawn(async move {
let mut config = config;
let mut client = match KokoroClient::new(&config.kokoro_url) {
Ok(client) => client,
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!("Kokoro config: {error}")));
return;
}
};
let mut voices = Vec::new();
let mut last_health_check = Instant::now() - Duration::from_secs(30);
let mut repeats = HashMap::<String, RepeatState>::new();
let mut queued = VecDeque::new();
loop {
while let Ok(updated_config) = control_receiver.try_recv() {
match KokoroClient::new(&updated_config.kokoro_url) {
Ok(updated_client) => {
config = updated_config;
client = updated_client;
voices.clear();
last_health_check = Instant::now() - Duration::from_secs(30);
let _ = event_sender.send(WorkerEvent::Activity(
"Kokoro settings updated; validating endpoint…".into(),
));
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"Kokoro endpoint rejected: {error}"
)));
}
}
}
if last_health_check.elapsed() >= Duration::from_secs(5) {
last_health_check = Instant::now();
let _ = event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Checking));
match client.health().await {
Ok(()) => match client.voices().await {
Ok(found) => {
if found != voices {
voices = found.clone();
let _ = event_sender.send(WorkerEvent::Voices(found));
}
let _ = event_sender
.send(WorkerEvent::KokoroStatus(KokoroStatus::Connected));
}
Err(error) => {
let _ = event_sender
.send(WorkerEvent::Activity(format!("voice list failed: {error}")));
let _ = event_sender
.send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable));
}
},
Err(_) => {
let _ =
event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable));
}
}
}
while let Ok(job) = jobs.try_recv() {
if queued.len() >= config.max_queue_length {
let _ = event_sender.send(WorkerEvent::Activity(
"speech queue full; line dropped".into(),
));
} else {
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, job.speed)
.await
{
Ok(wav) => {
if audio_sender.send(AudioCommand::Play(wav)).is_err() {
let _ = event_sender.send(WorkerEvent::Activity(
"audio worker stopped; unable to play speech".into(),
));
}
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"Kokoro speech failed: {error}"
)));
}
}
let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len()));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
});
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)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn tailer_starts_at_end_and_recovers_after_truncation() {
let directory = tempdir().unwrap();
let path = directory.path().join("eqlog.txt");
fs::write(&path, "old\n").unwrap();
let mut position = None;
assert!(read_new_lines(&path, &mut position).unwrap().is_empty());
fs::write(&path, "old\nnew\n").unwrap();
assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["new"]);
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
);
}
}