226 lines
7.2 KiB
Rust
226 lines
7.2 KiB
Rust
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<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 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<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.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(),
|
|
) {
|
|
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(())
|
|
}
|