Files
mudmouth/src/main.rs
T
pleb ddc49d9ceb
CI / macos-latest (push) Has been cancelled
CI / ubuntu-latest (push) Has been cancelled
CI / windows-latest (push) Has been cancelled
Filter noisy messages
2026-07-29 20:48:17 -07:00

241 lines
7.6 KiB
Rust

mod config;
mod eq_discovery;
mod events;
mod filters;
mod kokoro;
mod npc_voices;
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 filters::DialogueFilters;
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<PathBuf>,
/// Override the Kokoro-FastAPI endpoint for this run.
#[arg(long)]
kokoro_url: Option<String>,
}
struct RuntimeChannels {
worker_receiver: mpsc::Receiver<WorkerEvent>,
worker_sender: mpsc::Sender<WorkerEvent>,
synthesis_sender: tokio_mpsc::Sender<SynthesisJob>,
worker_control: tokio_mpsc::Sender<Config>,
audio_control: mpsc::Sender<AudioCommand>,
tailer_generation: Arc<AtomicUsize>,
filters: DialogueFilters,
}
#[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 _ = npc_voices::builtin_index();
let filters = DialogueFilters::load()?;
let speakers = speakers::SpeakerStore::load()?;
let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>();
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,
filters.clone(),
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,
filters,
};
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<CrosstermBackend<io::Stdout>>,
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.filters.clone(),
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<SynthesisJob>,
) -> 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(),
npc_voices::builtin_index(),
) {
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<SynthesisJob>,
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<Terminal<CrosstermBackend<io::Stdout>>> {
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<CrosstermBackend<io::Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}