Initialize repository for MUDMOUTH

This commit is contained in:
pleb
2026-07-14 13:44:32 -07:00
parent 13e12e7740
commit 54fa53222e
15 changed files with 5664 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
mod config;
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,
};
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>,
}
#[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::<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 let Some(path) = config.log_path.clone() {
spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0);
}
let mut app = App::new(config, speakers);
if app.config.log_path.is_none() {
app.activity
.push_front("First run: press [s] and configure your EverQuest log path.".into());
}
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<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() {
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<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,
speaker.clone(),
&app.voices,
app.current_zone.clone(),
) {
app.speakers.save()?;
if synthesis_sender
.try_send(SynthesisJob {
text: text.clone(),
voice,
})
.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 { text, voice })
.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(())
}