mod config; mod eq_discovery; mod events; mod kokoro; mod speakers; mod tui; mod workers; use anyhow::{Context, Result}; use clap::Parser; use config::Config; use crossterm::{ event::{self, Event}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use events::LogEvent; use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ io::{self, stdout}, path::PathBuf, sync::{ Arc, atomic::{AtomicUsize, Ordering}, mpsc, }, time::{Duration, Instant}, }; use tokio::sync::mpsc as tokio_mpsc; use tui::{App, UiAction}; use workers::{ AudioCommand, SynthesisJob, WorkerEvent, spawn_audio_worker, spawn_kokoro_worker, spawn_log_tailer, }; #[derive(Debug, Parser)] #[command(name = "mudmouth", version, about = "Give your EverQuest log a voice.")] struct Cli { /// Override the EverQuest log location for this run. #[arg(long)] log: Option, /// Override the Kokoro-FastAPI endpoint for this run. #[arg(long)] kokoro_url: Option, } struct RuntimeChannels { worker_receiver: mpsc::Receiver, worker_sender: mpsc::Sender, synthesis_sender: tokio_mpsc::Sender, worker_control: tokio_mpsc::Sender, audio_control: mpsc::Sender, tailer_generation: Arc, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let mut config = Config::load()?; if let Some(path) = cli.log { config.log_path = Some(path); } if let Some(url) = cli.kokoro_url { config.kokoro_url = url; } let speakers = speakers::SpeakerStore::load()?; let (worker_sender, worker_receiver) = mpsc::channel::(); let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone()); let (synthesis_sender, synthesis_receiver) = tokio_mpsc::channel(32); let worker_control = spawn_kokoro_worker( config.clone(), synthesis_receiver, audio_sender.clone(), worker_sender.clone(), ); let tailer_generation = Arc::new(AtomicUsize::new(0)); if config.log_path.as_ref().is_some_and(|path| path.is_file()) { let path = config.log_path.clone().expect("log path was checked"); spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0); } let mut app = App::new(config, speakers); let channels = RuntimeChannels { worker_receiver, worker_sender, synthesis_sender, worker_control, audio_control: audio_sender, tailer_generation, }; run_terminal(&mut app, channels).await } async fn run_terminal(app: &mut App, channels: RuntimeChannels) -> Result<()> { let mut terminal = setup_terminal()?; let result = run_loop(app, &mut terminal, channels).await; restore_terminal(&mut terminal)?; result } async fn run_loop( app: &mut App, terminal: &mut Terminal>, channels: RuntimeChannels, ) -> Result<()> { loop { while let Ok(event) = channels.worker_receiver.try_recv() { process_worker_event(app, event, &channels.synthesis_sender)?; } terminal.draw(|frame| tui::draw(frame, app))?; if event::poll(Duration::from_millis(80))? && let Event::Key(key) = event::read()? && let Some(action) = app.handle_key(key) { match action { UiAction::Quit => return Ok(()), UiAction::ConfigChanged => { app.config.save()?; app.speakers.save()?; let _ = channels.worker_control.try_send(app.config.clone()); let _ = channels .audio_control .send(AudioCommand::SetVolume(app.config.volume)); let generation = channels.tailer_generation.fetch_add(1, Ordering::Relaxed) + 1; if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) { spawn_log_tailer( path, channels.worker_sender.clone(), channels.tailer_generation.clone(), generation, ); } app.activity.push_front("settings saved".into()); } } } } } fn process_worker_event( app: &mut App, event: WorkerEvent, synthesis_sender: &tokio_mpsc::Sender, ) -> Result<()> { match &event { WorkerEvent::Log(LogEvent::ZoneTransition { zone, .. }) => { app.current_zone = Some(zone.clone()); if app.config.events.zone_transitions { enqueue_random_event(app, synthesis_sender, format!("You have entered {zone}."))?; } } WorkerEvent::Log(LogEvent::NpcDialogue { speaker, text, .. }) if app.config.events.npc_dialogue => { if app.voices.is_empty() { app.activity .push_front("NPC line seen, waiting for Kokoro voice catalog…".into()); } else { let key = events::speaker_key(speaker); if let Some(voice) = app.speakers.resolve_voice( key.clone(), speaker.clone(), &app.voices, app.current_zone.clone(), ) { app.speakers.save()?; if synthesis_sender .try_send(SynthesisJob { text: text.clone(), voice, speed: 1.0, repeat_key: format!("{key}\u{1f}{text}"), observed_at: Instant::now(), }) .is_err() { app.activity .push_front("speech queue is busy; line dropped".into()); } } } } _ => {} } app.receive(event); Ok(()) } fn enqueue_random_event( app: &mut App, synthesis_sender: &tokio_mpsc::Sender, text: String, ) -> Result<()> { use rand::prelude::IndexedRandom; if let Some(voice) = app.voices.choose(&mut rand::rng()).cloned() { synthesis_sender .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(()) } fn setup_terminal() -> Result>> { enable_raw_mode()?; let mut output = stdout(); execute!(output, EnterAlternateScreen)?; Terminal::new(CrosstermBackend::new(output)).context("could not start terminal UI") } fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) }