Initialize repository for MUDMOUTH
This commit is contained in:
+218
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user