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
+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
push:
pull_request:
jobs:
test:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test
- run: cargo build --release
+3
View File
@@ -0,0 +1,3 @@
/target
*.log
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
# AGENTS.md
Local kokoro-fastpi: http://127.0.0.1:8880/
Local log file example: ~/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt
## References
* The [OpenAPI spec for Kokoro FastAPI](/docs/kokoro-fastapi.json)
Generated
+2875
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "mudmouth"
version = "0.1.0"
edition = "2024"
description = "A classic MUD-inspired EverQuest log reader with Kokoro voices"
license = "MIT"
repository = "https://github.com/pleb/mudmouth"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
crossterm = "0.28"
dirs = "6"
rand = "0.9"
ratatui = "0.29"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rodio = { version = "0.21", default-features = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
toml = "0.8"
[dev-dependencies]
tempfile = "3"
[profile.release]
lto = true
codegen-units = 1
strip = true
+113
View File
@@ -0,0 +1,113 @@
# Mudmouth
```text
_ _ _
_ __ ___ _ _ __| |_ __ ___ ___ | | | |_ ____
| '_ ` _ \| | | |/ _` | '_ ` _ \ / _ \| |_| | '_ /
| | | | | | |_| | (_| | | | | | | (_) | _ | | | |
|_| |_| |_|\__,_|\__,_|_| |_| |_|\___/|_| |_|_| |_|
```
Mudmouth is a cross-platform, classic-MUD-inspired TUI for EverQuest Legends.
It follows an EverQuest log, gives NPC dialogue a Kokoro-FastAPI voice, and
remembers the chosen voice for each NPC. It is entirely local: your log,
speaker mappings, generated audio, and TTS endpoint stay on your computer.
## What it does
- Watches an EverQuest log without replaying historical lines on startup.
- Speaks `Name says, '…'` NPC dialogue by default.
- Calls `GET /health`, shows live Kokoro connectivity in the status bar, and
refreshes its selectable voice catalog from `GET /v1/audio/voices`.
- Sends non-streaming WAV requests to `POST /v1/audio/speech` and plays clips
sequentially with native audio support.
- Picks a real voice from your Kokoro server for a newly seen NPC and saves it.
- Lets you pin an NPC to a particular voice, reroll a persistent random voice,
or set the NPC to get a new random voice for every line.
- Can optionally narrate zone entries (`You have entered Lake Rathetear.`).
This is disabled by default, as are future optional message categories.
## Requirements
- Rust stable (edition 2024)
- A terminal with ANSI support: Windows Terminal, iTerm2, Terminal.app, GNOME
Terminal, and similar terminals work well.
- A reachable [Kokoro-FastAPI](docs/kokoro-fastapi.json) instance. The default
is `http://127.0.0.1:8880`.
- An EverQuest log with `/log on`.
No `ffplay`, `mpv`, PipeWire command, or other external audio player is
required; Mudmouth plays returned WAV audio itself.
## Build and run
On NixOS, enter the included development shell. It provides the ALSA
development metadata required by the native Linux audio backend:
```bash
nix-shell --run 'cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"'
```
On other platforms, or when Rust and the native audio development packages are
already installed:
```bash
cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"
```
Or persist the paths through the app:
```bash
cargo run --release
```
Press `s`, select **EverQuest log**, and enter the file path. Change the
Kokoro endpoint from the same screen. Mudmouth validates the endpoint in the
background and the header reads `CONNECTED`, `CHECKING`, or `UNREACHABLE`.
Changing a log path starts watching the new file immediately. A command-line
endpoint override is also available:
```bash
cargo run --release -- --kokoro-url http://127.0.0.1:8880
```
## TUI controls
| Key | Action |
| --- | --- |
| `d` | Dashboard / live tavern scroll |
| `s` | Settings |
| `v` | NPC voice manager |
| `?` | Help |
| `q` | Quit |
| `↑` / `↓`, `j` / `k` | Move selection |
| `Enter`, `Space` | Edit or toggle a setting |
| `←` / `→`, `h` / `l` | Choose a voice |
| `p` | Pin selected NPC to selected voice |
| `r` | Random voice every NPC line |
| `x` | Choose a fresh persistent random voice |
| `Esc` | Close a menu or edit box |
## Data and privacy
Mudmouth writes only two human-readable files in the operating system's
standard application-data directory:
- `config.toml` — endpoint, log path, event toggles, and playback settings
- `speakers.json` — NPC names, voice modes, and last-seen context
On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform
directory is used on macOS and Windows.
## Development
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo build --release
```
The parser tests cover the observed log formats, including NPC dialogue,
channel-chat exclusion, zone events, normalization, persistent/random speaker
voice policies, and log truncation.
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
pkg-config
alsa-lib
];
PKG_CONFIG_PATH = "${pkgs.alsa-lib.dev}/lib/pkgconfig";
}
+111
View File
@@ -0,0 +1,111 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::{Path, PathBuf},
};
pub const APP_DIRECTORY: &str = "mudmouth";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub kokoro_url: String,
pub log_path: Option<PathBuf>,
pub volume: f32,
pub max_queue_length: usize,
pub dedup_window_seconds: u64,
pub events: EventSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EventSettings {
pub npc_dialogue: bool,
pub zone_transitions: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
kokoro_url: "http://127.0.0.1:8880".into(),
log_path: None,
volume: 0.85,
max_queue_length: 10,
dedup_window_seconds: 5,
events: EventSettings::default(),
}
}
}
impl Default for EventSettings {
fn default() -> Self {
Self {
npc_dialogue: true,
zone_transitions: false,
}
}
}
impl Config {
pub fn app_dir() -> Result<PathBuf> {
dirs::data_local_dir()
.or_else(dirs::data_dir)
.map(|path| path.join(APP_DIRECTORY))
.context("could not determine a platform data directory")
}
pub fn path() -> Result<PathBuf> {
Ok(Self::app_dir()?.join("config.toml"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
return Ok(Self::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
toml::from_str(&contents).context("could not parse Mudmouth config")
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
atomic_write(&path, toml::to_string_pretty(self)?.as_bytes())
}
}
pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path
.parent()
.context("cannot save a file without a parent directory")?;
fs::create_dir_all(parent)?;
let temporary = path.with_extension("tmp");
fs::write(&temporary, contents)
.with_context(|| format!("could not write {}", temporary.display()))?;
fs::rename(&temporary, path)
.with_context(|| format!("could not replace {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn atomically_replaces_config_contents() {
let directory = tempdir().unwrap();
let path = directory.path().join("config.toml");
atomic_write(&path, b"volume = 0.5\n").unwrap();
atomic_write(&path, b"volume = 0.8\n").unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), "volume = 0.8\n");
}
#[test]
fn config_defaults_enable_only_npc_dialogue() {
let config = Config::default();
assert!(config.events.npc_dialogue);
assert!(!config.events.zone_transitions);
}
}
+143
View File
@@ -0,0 +1,143 @@
use chrono::{DateTime, Local, NaiveDateTime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogEvent {
NpcDialogue {
at: DateTime<Local>,
speaker: String,
text: String,
},
ZoneTransition {
at: DateTime<Local>,
zone: String,
},
}
impl LogEvent {
pub fn label(&self) -> &'static str {
match self {
Self::NpcDialogue { .. } => "NPC",
Self::ZoneTransition { .. } => "ZONE",
}
}
pub fn text(&self) -> &str {
match self {
Self::NpcDialogue { text, .. } => text,
Self::ZoneTransition { zone, .. } => zone,
}
}
pub fn occurred_at(&self) -> DateTime<Local> {
match self {
Self::NpcDialogue { at, .. } | Self::ZoneTransition { at, .. } => *at,
}
}
}
pub fn parse_line(line: &str) -> Option<LogEvent> {
let (timestamp, message) = split_timestamp(line)?;
if let Some((speaker, text)) = parse_npc_says(message) {
return Some(LogEvent::NpcDialogue {
at: timestamp,
speaker,
text,
});
}
message
.strip_prefix("You have entered ")
.and_then(|zone| zone.strip_suffix('.'))
.filter(|zone| !zone.is_empty())
.map(|zone| LogEvent::ZoneTransition {
at: timestamp,
zone: zone.to_string(),
})
}
fn split_timestamp(line: &str) -> Option<(DateTime<Local>, &str)> {
let end = line.find("] ")?;
let raw_timestamp = line.strip_prefix('[')?.get(..end - 1)?;
let timestamp = NaiveDateTime::parse_from_str(raw_timestamp, "%a %b %d %H:%M:%S %Y")
.ok()?
.and_local_timezone(Local)
.single()?;
Some((timestamp, &line[end + 2..]))
}
fn parse_npc_says(message: &str) -> Option<(String, String)> {
let (speaker, quote) = message.split_once(" says, '")?;
let text = quote.strip_suffix('\'')?;
if speaker.is_empty() || text.is_empty() || speaker == "You" {
return None;
}
Some((speaker.to_string(), text.to_string()))
}
pub fn speaker_key(name: &str) -> String {
let normalized = name
.chars()
.flat_map(char::to_lowercase)
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect::<String>();
let normalized = normalized.trim_matches('_');
let compact = normalized
.split('_')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("_");
format!("npc:{compact}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_npc_dialogue_with_apostrophe() {
let event =
parse_line("[Fri Jul 17 20:10:29 2026] Turga says, 'I've got a Rusty Long Sword.'")
.unwrap();
assert_eq!(
event,
LogEvent::NpcDialogue {
at: NaiveDateTime::parse_from_str(
"Fri Jul 17 20:10:29 2026",
"%a %b %d %H:%M:%S %Y"
)
.unwrap()
.and_local_timezone(Local)
.single()
.unwrap(),
speaker: "Turga".into(),
text: "I've got a Rusty Long Sword.".into(),
}
);
}
#[test]
fn ignores_player_dialogue_and_channel_tells() {
assert!(parse_line("[Fri Jul 17 20:10:29 2026] You say, 'Hail'").is_none());
assert!(parse_line("[Fri Jul 17 20:10:29 2026] Rath tells General:2, 'hello'").is_none());
}
#[test]
fn parses_zone_message() {
let event =
parse_line("[Fri Jul 17 20:09:32 2026] You have entered Lake Rathetear.").unwrap();
assert!(matches!(event, LogEvent::ZoneTransition { zone, .. } if zone == "Lake Rathetear"));
}
#[test]
fn normalizes_speaker_keys() {
assert_eq!(speaker_key("A lizardman healer"), "npc:a_lizardman_healer");
assert_eq!(speaker_key("Zok Caropni!"), "npc:zok_caropni");
}
}
+176
View File
@@ -0,0 +1,176 @@
use anyhow::{Context, Result};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Clone)]
pub struct KokoroClient {
client: reqwest::Client,
base_url: Url,
}
#[derive(Debug, Deserialize)]
pub struct Voice {
pub id: String,
#[allow(dead_code)]
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum VoiceResponse {
Wrapped { voices: Vec<Voice> },
Bare(Vec<Voice>),
}
impl VoiceResponse {
fn into_voices(self) -> Vec<Voice> {
match self {
Self::Wrapped { voices } | Self::Bare(voices) => voices,
}
}
}
#[derive(Serialize)]
struct SpeechRequest<'a> {
input: &'a str,
voice: &'a str,
model: &'static str,
response_format: &'static str,
stream: bool,
}
impl KokoroClient {
pub fn new(base_url: &str) -> Result<Self> {
let normalized = format!("{}/", base_url.trim_end_matches('/'));
Ok(Self {
client: reqwest::Client::builder()
.connect_timeout(Duration::from_secs(2))
.timeout(Duration::from_secs(20))
.build()?,
base_url: Url::parse(&normalized).context("Kokoro endpoint is not a valid URL")?,
})
}
fn endpoint(&self, path: &str) -> Result<Url> {
self.base_url
.join(path)
.context("could not build Kokoro URL")
}
pub async fn health(&self) -> Result<()> {
self.client
.get(self.endpoint("health")?)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn voices(&self) -> Result<Vec<String>> {
let response = self
.client
.get(self.endpoint("v1/audio/voices")?)
.send()
.await?
.error_for_status()?;
let response: VoiceResponse = response.json().await?;
Ok(response
.into_voices()
.into_iter()
.map(|voice| voice.id)
.collect())
}
pub async fn synthesize_wav(&self, text: &str, voice: &str) -> Result<Vec<u8>> {
let response = self
.client
.post(self.endpoint("v1/audio/speech")?)
.json(&SpeechRequest {
input: text,
voice,
model: "kokoro",
response_format: "wav",
stream: false,
})
.send()
.await?
.error_for_status()?;
Ok(response.bytes().await?.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
fn serve_once(status: &str, content_type: &str, body: &'static [u8]) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let status = status.to_owned();
let content_type = content_type.to_owned();
thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let count = stream.read(&mut request).unwrap();
assert!(count > 0);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(response.as_bytes()).unwrap();
stream.write_all(body).unwrap();
});
format!("http://{address}")
}
#[tokio::test]
async fn reads_voice_catalog_from_mock_server() {
let url = serve_once(
"200 OK",
"application/json",
br#"[{"id":"af_heart","name":"Heart"},{"id":"am_adam","name":"Adam"}]"#,
);
let voices = KokoroClient::new(&url).unwrap().voices().await.unwrap();
assert_eq!(voices, vec!["af_heart", "am_adam"]);
}
#[tokio::test]
async fn reads_wrapped_voice_catalog_from_mock_server() {
let url = serve_once(
"200 OK",
"application/json",
br#"{"voices":[{"id":"af_heart","name":"Heart"}]}"#,
);
let voices = KokoroClient::new(&url).unwrap().voices().await.unwrap();
assert_eq!(voices, vec!["af_heart"]);
}
#[tokio::test]
async fn returns_wav_body_from_mock_server() {
let url = serve_once("200 OK", "audio/wav", b"RIFFmock");
let wav = KokoroClient::new(&url)
.unwrap()
.synthesize_wav("hello", "af_heart")
.await
.unwrap();
assert_eq!(wav, b"RIFFmock");
}
#[tokio::test]
async fn treats_non_success_as_failure() {
let url = serve_once("503 Service Unavailable", "application/json", b"{}");
assert!(KokoroClient::new(&url).unwrap().health().await.is_err());
}
#[tokio::test]
async fn rejects_malformed_voice_catalog() {
let url = serve_once("200 OK", "application/json", br#"{"voices":"not a list"}"#);
assert!(KokoroClient::new(&url).unwrap().voices().await.is_err());
}
}
+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(())
}
+141
View File
@@ -0,0 +1,141 @@
use crate::config::{Config, atomic_write};
use anyhow::{Context, Result};
use chrono::{DateTime, Local};
use rand::prelude::IndexedRandom;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, path::PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum VoiceMode {
PersistentRandom { voice: String },
Pinned { voice: String },
RandomEachUtterance,
}
impl VoiceMode {
pub fn display(&self) -> String {
match self {
Self::PersistentRandom { voice } => format!("random → {voice}"),
Self::Pinned { voice } => format!("pinned → {voice}"),
Self::RandomEachUtterance => "random every line".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Speaker {
pub name: String,
pub mode: VoiceMode,
pub last_seen: DateTime<Local>,
pub last_zone: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SpeakerStore {
speakers: BTreeMap<String, Speaker>,
}
impl SpeakerStore {
fn path() -> Result<PathBuf> {
Ok(Config::app_dir()?.join("speakers.json"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
return Ok(Self::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
serde_json::from_str(&contents).context("could not parse speaker database")
}
pub fn save(&self) -> Result<()> {
atomic_write(&Self::path()?, serde_json::to_vec_pretty(self)?.as_slice())
}
pub fn resolve_voice(
&mut self,
key: String,
name: String,
voices: &[String],
zone: Option<String>,
) -> Option<String> {
let now = Local::now();
let speaker = self.speakers.entry(key).or_insert_with(|| {
let voice = choose_voice(voices).unwrap_or_else(|| "af_heart".into());
Speaker {
name,
mode: VoiceMode::PersistentRandom { voice },
last_seen: now,
last_zone: zone.clone(),
}
});
speaker.last_seen = now;
speaker.last_zone = zone;
match &speaker.mode {
VoiceMode::PersistentRandom { voice } | VoiceMode::Pinned { voice } => {
Some(voice.clone())
}
VoiceMode::RandomEachUtterance => choose_voice(voices),
}
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &Speaker)> {
self.speakers.iter()
}
pub fn get(&self, key: &str) -> Option<&Speaker> {
self.speakers.get(key)
}
pub fn pin(&mut self, key: &str, voice: String) {
if let Some(speaker) = self.speakers.get_mut(key) {
speaker.mode = VoiceMode::Pinned { voice };
}
}
pub fn set_random_each_utterance(&mut self, key: &str) {
if let Some(speaker) = self.speakers.get_mut(key) {
speaker.mode = VoiceMode::RandomEachUtterance;
}
}
pub fn reset_persistent_random(&mut self, key: &str, voices: &[String]) {
if let (Some(speaker), Some(voice)) = (self.speakers.get_mut(key), choose_voice(voices)) {
speaker.mode = VoiceMode::PersistentRandom { voice };
}
}
}
fn choose_voice(voices: &[String]) -> Option<String> {
voices.choose(&mut rand::rng()).cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_assignment_is_persisted() {
let mut store = SpeakerStore::default();
let voices = vec!["af_heart".into(), "am_adam".into()];
let first = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None);
let next = store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None);
assert_eq!(first, next);
}
#[test]
fn random_each_utterance_remains_configurable() {
let mut store = SpeakerStore::default();
let voices = vec!["af_heart".into()];
store.resolve_voice("npc:turga".into(), "Turga".into(), &voices, None);
store.set_random_each_utterance("npc:turga");
assert_eq!(
store.get("npc:turga").unwrap().mode,
VoiceMode::RandomEachUtterance
);
}
}
+552
View File
@@ -0,0 +1,552 @@
use crate::{
config::Config,
speakers::SpeakerStore,
workers::{KokoroStatus, WorkerEvent},
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use std::collections::VecDeque;
const LOGO: &str = r#"
███╗ ███╗██╗ ██╗██████╗ ███╗ ███╗ ██████╗ ██╗ ██╗████████╗██╗ ██╗
████╗ ████║██║ ██║██╔══██╗████╗ ████║██╔═══██╗██║ ██║╚══██╔══╝██║ ██║
██╔████╔██║██║ ██║██║ ██║██╔████╔██║██║ ██║██║ ██║ ██║ ███████║
██║╚██╔╝██║██║ ██║██║ ██║██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██╔══██║
██║ ╚═╝ ██║╚██████╔╝██████╔╝██║ ╚═╝ ██║╚██████╔╝╚██████╔╝ ██║ ██║ ██║
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
Settings,
Speakers,
Help,
}
#[derive(Debug)]
pub enum UiAction {
Quit,
ConfigChanged,
}
pub struct App {
pub config: Config,
pub speakers: SpeakerStore,
pub screen: Screen,
pub kokoro_status: KokoroStatus,
pub voices: Vec<String>,
pub current_zone: Option<String>,
pub queue_depth: usize,
pub activity: VecDeque<String>,
pub selected_speaker: usize,
pub selected_voice: usize,
pub settings_cursor: usize,
pub speaker_filter: String,
pub splash_until: std::time::Instant,
pub input: Option<Input>,
}
#[derive(Debug)]
pub enum Input {
Endpoint(String),
LogPath(String),
SpeakerSearch(String),
}
impl App {
pub fn new(config: Config, speakers: SpeakerStore) -> Self {
Self {
config,
speakers,
screen: Screen::Dashboard,
kokoro_status: KokoroStatus::Checking,
voices: Vec::new(),
current_zone: None,
queue_depth: 0,
activity: VecDeque::from(["The tavern is quiet. Waiting for log lines…".into()]),
selected_speaker: 0,
selected_voice: 0,
settings_cursor: 0,
speaker_filter: String::new(),
splash_until: std::time::Instant::now() + std::time::Duration::from_millis(1700),
input: None,
}
}
pub fn receive(&mut self, event: WorkerEvent) {
match event {
WorkerEvent::KokoroStatus(status) => self.kokoro_status = status,
WorkerEvent::Voices(voices) => {
self.voices = voices;
self.activity
.push_front(format!("{} Kokoro voices loaded", self.voices.len()));
}
WorkerEvent::QueueDepth(depth) => self.queue_depth = depth,
WorkerEvent::Activity(message) => self.activity.push_front(message),
WorkerEvent::Log(event) => self.activity.push_front(format!(
"[{} {}] {}",
event.occurred_at().format("%H:%M:%S"),
event.label(),
event.text()
)),
}
self.activity.truncate(80);
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<UiAction> {
if self.input.is_some() {
return self.handle_input(key);
}
match key.code {
KeyCode::Char('q') => Some(UiAction::Quit),
KeyCode::Char('?') => {
self.screen = Screen::Help;
None
}
KeyCode::Esc => {
self.screen = Screen::Dashboard;
None
}
KeyCode::Char('d') => {
self.screen = Screen::Dashboard;
None
}
KeyCode::Char('s') => {
self.screen = Screen::Settings;
None
}
KeyCode::Char('v') => {
self.screen = Screen::Speakers;
None
}
_ => match self.screen {
Screen::Settings => self.handle_settings_key(key),
Screen::Speakers => self.handle_speakers_key(key),
_ => None,
},
}
}
fn handle_input(&mut self, key: KeyEvent) -> Option<UiAction> {
let mut input = self.input.take().expect("input state was checked");
match key.code {
KeyCode::Esc => {}
KeyCode::Enter => {
let mut close = false;
let changed = match &input {
Input::Endpoint(value) => {
self.config.kokoro_url = value.trim().trim_end_matches('/').to_string();
true
}
Input::LogPath(value) => {
self.config.log_path = (!value.trim().is_empty())
.then(|| std::path::PathBuf::from(value.trim()));
true
}
Input::SpeakerSearch(value) => {
self.speaker_filter = value.trim().to_lowercase();
self.selected_speaker = 0;
close = true;
false
}
};
if changed {
return Some(UiAction::ConfigChanged);
}
if close {
return None;
}
}
KeyCode::Backspace => match &mut input {
Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => {
value.pop();
}
},
KeyCode::Char(character) => match &mut input {
Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => {
value.push(character);
}
},
_ => {}
}
self.input = Some(input);
None
}
fn handle_settings_key(&mut self, key: KeyEvent) -> Option<UiAction> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.settings_cursor = self.settings_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.settings_cursor = (self.settings_cursor + 1).min(4)
}
KeyCode::Enter | KeyCode::Char(' ') => match self.settings_cursor {
0 => self.input = Some(Input::Endpoint(self.config.kokoro_url.clone())),
1 => {
self.input = Some(Input::LogPath(
self.config
.log_path
.as_ref()
.map_or_else(String::new, |path| path.display().to_string()),
))
}
2 => {
self.config.events.npc_dialogue = !self.config.events.npc_dialogue;
return Some(UiAction::ConfigChanged);
}
3 => {
self.config.events.zone_transitions = !self.config.events.zone_transitions;
return Some(UiAction::ConfigChanged);
}
4 => {
self.config.volume = if self.config.volume >= 1.0 {
0.2
} else {
(self.config.volume + 0.2).min(1.0)
};
return Some(UiAction::ConfigChanged);
}
_ => {}
},
_ => {}
}
None
}
fn handle_speakers_key(&mut self, key: KeyEvent) -> Option<UiAction> {
let keys = self.speaker_keys();
match key.code {
KeyCode::Char('f') => {
self.input = Some(Input::SpeakerSearch(self.speaker_filter.clone()));
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected_speaker = self.selected_speaker.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected_speaker =
(self.selected_speaker + 1).min(keys.len().saturating_sub(1))
}
KeyCode::Left | KeyCode::Char('h') => {
self.selected_voice = self.selected_voice.saturating_sub(1)
}
KeyCode::Right | KeyCode::Char('l') => {
self.selected_voice =
(self.selected_voice + 1).min(self.voices.len().saturating_sub(1))
}
KeyCode::Char('p') | KeyCode::Enter => {
if let (Some(key), Some(voice)) = (
keys.get(self.selected_speaker),
self.voices.get(self.selected_voice),
) {
self.speakers.pin(key, voice.clone());
return Some(UiAction::ConfigChanged);
}
}
KeyCode::Char('r') => {
if let Some(key) = keys.get(self.selected_speaker) {
self.speakers.set_random_each_utterance(key);
return Some(UiAction::ConfigChanged);
}
}
KeyCode::Char('x') => {
if let Some(key) = keys.get(self.selected_speaker) {
self.speakers.reset_persistent_random(key, &self.voices);
return Some(UiAction::ConfigChanged);
}
}
_ => {}
}
None
}
pub fn speaker_keys(&self) -> Vec<String> {
self.speakers
.entries()
.filter(|(_, speaker)| {
self.speaker_filter.is_empty()
|| speaker.name.to_lowercase().contains(&self.speaker_filter)
})
.map(|(key, _)| key.clone())
.collect()
}
}
pub fn draw(frame: &mut Frame, app: &App) {
if std::time::Instant::now() < app.splash_until {
draw_splash(frame);
return;
}
let area = frame.area();
let sections = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(2),
])
.split(area);
draw_header(frame, app, sections[0]);
match app.screen {
Screen::Dashboard => draw_dashboard(frame, app, sections[1]),
Screen::Settings => draw_settings(frame, app, sections[1]),
Screen::Speakers => draw_speakers(frame, app, sections[1]),
Screen::Help => draw_help(frame, sections[1]),
}
frame.render_widget(
Paragraph::new(" [d] dashboard [s] settings [v] voices [?] help [q] quit ")
.style(Style::default().fg(Color::DarkGray)),
sections[2],
);
if let Some(input) = &app.input {
draw_input(frame, input);
}
}
fn draw_splash(frame: &mut Frame) {
let layout = Layout::vertical([
Constraint::Percentage(25),
Constraint::Length(7),
Constraint::Length(2),
Constraint::Percentage(25),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(LOGO)
.alignment(Alignment::Center)
.style(Style::default().fg(Color::LightMagenta)),
layout[1],
);
frame.render_widget(
Paragraph::new("THE LOG AWAKENS • THE DUNGEON SPEAKS")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Yellow)),
layout[2],
);
}
fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
let status = match app.kokoro_status {
KokoroStatus::Checking => "◌ KOKORO: CHECKING",
KokoroStatus::Connected => "● KOKORO: CONNECTED",
KokoroStatus::Unreachable => "× KOKORO: UNREACHABLE",
};
let color = match app.kokoro_status {
KokoroStatus::Checking => Color::Yellow,
KokoroStatus::Connected => Color::Green,
KokoroStatus::Unreachable => Color::Red,
};
let title = Line::from(vec![
Span::styled(
" MUDMOUTH ",
Style::default().fg(Color::LightMagenta).bold(),
),
Span::raw(" // "),
Span::styled(status, Style::default().fg(color).bold()),
Span::raw(format!(" Queue: {}", app.queue_depth)),
]);
frame.render_widget(
Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
area,
);
}
fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) {
let columns =
Layout::horizontal([Constraint::Percentage(68), Constraint::Percentage(32)]).split(area);
let items = app
.activity
.iter()
.map(|line| ListItem::new(line.as_str()))
.collect::<Vec<_>>();
frame.render_widget(
List::new(items)
.block(
Block::default()
.title(" LIVE TAVERN SCROLL ")
.borders(Borders::ALL),
)
.style(Style::default().fg(Color::Gray)),
columns[0],
);
let log = app.config.log_path.as_ref().map_or_else(
|| "not configured".into(),
|path| path.display().to_string(),
);
let details = format!(
"LOG\n{log}\n\nZONE\n{}\n\nVOICE CATALOG\n{} voices\n\nDEFAULTS\nNPC dialogue: {}\nZone transitions: {}",
app.current_zone.as_deref().unwrap_or("unknown"),
app.voices.len(),
on_off(app.config.events.npc_dialogue),
on_off(app.config.events.zone_transitions)
);
frame.render_widget(
Paragraph::new(details)
.block(
Block::default()
.title(" WORLD STATE ")
.borders(Borders::ALL),
)
.wrap(Wrap { trim: true }),
columns[1],
);
}
fn draw_settings(frame: &mut Frame, app: &App, area: Rect) {
let fields = [
format!("Kokoro endpoint: {}", app.config.kokoro_url),
format!(
"EverQuest log: {}",
app.config.log_path.as_ref().map_or_else(
|| "not configured".into(),
|path| path.display().to_string()
)
),
format!(
"Speak NPC dialogue: {}",
on_off(app.config.events.npc_dialogue)
),
format!(
"Speak zone transitions: {}",
on_off(app.config.events.zone_transitions)
),
format!("Playback volume: {:.0}%", app.config.volume * 100.0),
];
let items = fields
.iter()
.enumerate()
.map(|(index, field)| {
ListItem::new(field.clone()).style(if index == app.settings_cursor {
Style::default().fg(Color::Yellow).bold()
} else {
Style::default()
})
})
.collect::<Vec<_>>();
frame.render_widget(
List::new(items).block(
Block::default()
.title(" SETTINGS — ↑↓ select, Enter edit/toggle ")
.borders(Borders::ALL),
),
area,
);
}
fn draw_speakers(frame: &mut Frame, app: &App, area: Rect) {
let columns =
Layout::horizontal([Constraint::Percentage(52), Constraint::Percentage(48)]).split(area);
let keys = app.speaker_keys();
let speakers = keys
.iter()
.enumerate()
.filter_map(|(index, key)| {
app.speakers.get(key).map(|speaker| {
ListItem::new(format!("{} {}", speaker.name, speaker.mode.display())).style(
if index == app.selected_speaker {
Style::default().fg(Color::Yellow).bold()
} else {
Style::default()
},
)
})
})
.collect::<Vec<_>>();
frame.render_widget(
List::new(speakers).block(
Block::default()
.title(format!(
" NPCS — ↑↓ choose; [f] filter{} ",
if app.speaker_filter.is_empty() {
String::new()
} else {
format!(": {}", app.speaker_filter)
}
))
.borders(Borders::ALL),
),
columns[0],
);
let voices = app
.voices
.iter()
.enumerate()
.map(|(index, voice)| {
ListItem::new(voice.as_str()).style(if index == app.selected_voice {
Style::default().fg(Color::LightCyan).bold()
} else {
Style::default()
})
})
.collect::<Vec<_>>();
frame.render_widget(
List::new(voices).block(
Block::default()
.title(" VOICES — ←→ choose; [p] pin [r] random/line [x] reset ")
.borders(Borders::ALL),
),
columns[1],
);
}
fn draw_help(frame: &mut Frame, area: Rect) {
frame.render_widget(
Paragraph::new(
"Mudmouth gives EverQuests NPCs a voice.\n\n\
[d] dashboard [s] settings [v] NPC voice manager\n\
[f] filter NPCs in the voice manager\n\
[p] pin the selected NPC to the selected Kokoro voice\n\
[r] change selected NPC to random every utterance\n\
[x] assign a fresh persistent random voice\n\
[q] quit\n\n\
NPC dialogue is on by default. Zone announcements are off by default.\n\
The KOKORO status line tells you whether voice generation is reachable.",
)
.block(
Block::default()
.title(" GRIMOIRE / HELP ")
.borders(Borders::ALL),
)
.wrap(Wrap { trim: true }),
area,
);
}
fn draw_input(frame: &mut Frame, input: &Input) {
let area = centered_rect(80, 20, frame.area());
let (label, value) = match input {
Input::Endpoint(value) => ("Kokoro endpoint", value),
Input::LogPath(value) => ("EverQuest log path", value),
Input::SpeakerSearch(value) => ("Search NPC", value),
};
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(format!("{value}_")).block(
Block::default()
.title(format!(" {label} — Enter save, Esc cancel "))
.borders(Borders::ALL),
),
area,
);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - height) / 2),
Constraint::Percentage(height),
Constraint::Percentage((100 - height) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - width) / 2),
Constraint::Percentage(width),
Constraint::Percentage((100 - width) / 2),
])
.split(vertical[1])[1]
}
fn on_off(value: bool) -> &'static str {
if value { "ON" } else { "OFF" }
}
+259
View File
@@ -0,0 +1,259 @@
use crate::{
config::Config,
events::{LogEvent, parse_line},
kokoro::KokoroClient,
};
use anyhow::Result;
use rodio::{Decoder, OutputStreamBuilder, Sink};
use std::{
collections::{HashMap, VecDeque},
fs::File,
io::{BufRead, BufReader, Cursor, Seek, SeekFrom},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc::{self, Receiver, Sender},
},
thread,
time::{Duration, Instant},
};
use tokio::sync::mpsc as tokio_mpsc;
#[derive(Debug, Clone)]
pub enum WorkerEvent {
Log(LogEvent),
Activity(String),
KokoroStatus(KokoroStatus),
Voices(Vec<String>),
QueueDepth(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KokoroStatus {
Checking,
Connected,
Unreachable,
}
#[derive(Debug)]
pub struct SynthesisJob {
pub text: String,
pub voice: String,
}
#[derive(Debug)]
pub enum AudioCommand {
Play(Vec<u8>),
SetVolume(f32),
}
pub fn spawn_log_tailer(
path: PathBuf,
sender: Sender<WorkerEvent>,
generation: Arc<AtomicUsize>,
expected_generation: usize,
) {
thread::spawn(move || {
let mut position = None;
while generation.load(Ordering::Relaxed) == expected_generation {
match read_new_lines(&path, &mut position) {
Ok(lines) => {
for line in lines {
if let Some(event) = parse_line(&line) {
let _ = sender.send(WorkerEvent::Log(event));
}
}
}
Err(error) => {
let _ = sender.send(WorkerEvent::Activity(format!("log watch: {error}")));
}
}
thread::sleep(Duration::from_millis(350));
}
});
}
fn read_new_lines(path: &Path, position: &mut Option<u64>) -> Result<Vec<String>> {
let mut file = File::open(path)?;
let length = file.metadata()?.len();
let offset = position.unwrap_or(length);
let offset = if length < offset { 0 } else { offset };
file.seek(SeekFrom::Start(offset))?;
let reader = BufReader::new(&file);
let lines = reader.lines().collect::<std::io::Result<Vec<_>>>()?;
*position = Some(length);
Ok(lines)
}
pub fn spawn_audio_worker(volume: f32, event_sender: Sender<WorkerEvent>) -> Sender<AudioCommand> {
let (sender, receiver): (Sender<AudioCommand>, Receiver<AudioCommand>) = mpsc::channel();
thread::spawn(move || {
let stream = match OutputStreamBuilder::open_default_stream() {
Ok(stream) => stream,
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"audio output unavailable: {error}"
)));
return;
}
};
let mut volume = volume;
while let Ok(command) = receiver.recv() {
let wav = match command {
AudioCommand::Play(wav) => wav,
AudioCommand::SetVolume(updated_volume) => {
volume = updated_volume;
continue;
}
};
let _ = event_sender.send(WorkerEvent::QueueDepth(1));
match Decoder::try_from(Cursor::new(wav)) {
Ok(source) => {
let sink = Sink::connect_new(stream.mixer());
sink.set_volume(volume);
sink.append(source);
sink.sleep_until_end();
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"audio decode failed: {error}"
)));
}
}
let _ = event_sender.send(WorkerEvent::QueueDepth(0));
}
});
sender
}
pub fn spawn_kokoro_worker(
config: Config,
mut jobs: tokio_mpsc::Receiver<SynthesisJob>,
audio_sender: Sender<AudioCommand>,
event_sender: Sender<WorkerEvent>,
) -> tokio_mpsc::Sender<Config> {
let (control_sender, mut control_receiver) = tokio_mpsc::channel::<Config>(4);
tokio::spawn(async move {
let mut config = config;
let mut client = match KokoroClient::new(&config.kokoro_url) {
Ok(client) => client,
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!("Kokoro config: {error}")));
return;
}
};
let mut voices = Vec::new();
let mut last_health_check = Instant::now() - Duration::from_secs(30);
let mut recent = HashMap::<String, Instant>::new();
let mut queued = VecDeque::new();
loop {
while let Ok(updated_config) = control_receiver.try_recv() {
match KokoroClient::new(&updated_config.kokoro_url) {
Ok(updated_client) => {
config = updated_config;
client = updated_client;
voices.clear();
last_health_check = Instant::now() - Duration::from_secs(30);
let _ = event_sender.send(WorkerEvent::Activity(
"Kokoro settings updated; validating endpoint…".into(),
));
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"Kokoro endpoint rejected: {error}"
)));
}
}
}
if last_health_check.elapsed() >= Duration::from_secs(5) {
last_health_check = Instant::now();
let _ = event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Checking));
match client.health().await {
Ok(()) => match client.voices().await {
Ok(found) => {
if found != voices {
voices = found.clone();
let _ = event_sender.send(WorkerEvent::Voices(found));
}
let _ = event_sender
.send(WorkerEvent::KokoroStatus(KokoroStatus::Connected));
}
Err(error) => {
let _ = event_sender
.send(WorkerEvent::Activity(format!("voice list failed: {error}")));
let _ = event_sender
.send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable));
}
},
Err(_) => {
let _ =
event_sender.send(WorkerEvent::KokoroStatus(KokoroStatus::Unreachable));
}
}
}
while let Ok(job) = jobs.try_recv() {
let key = format!("{}:{}", job.voice, job.text);
let is_duplicate = recent.get(&key).is_some_and(|last| {
last.elapsed() < Duration::from_secs(config.dedup_window_seconds)
});
if is_duplicate {
let _ =
event_sender.send(WorkerEvent::Activity("duplicate line skipped".into()));
} else if queued.len() >= config.max_queue_length {
let _ = event_sender.send(WorkerEvent::Activity(
"speech queue full; line dropped".into(),
));
} else {
recent.insert(key, Instant::now());
queued.push_back(job);
}
}
if let Some(job) = queued.pop_front() {
let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len() + 1));
match client.synthesize_wav(&job.text, &job.voice).await {
Ok(wav) => {
if audio_sender.send(AudioCommand::Play(wav)).is_err() {
let _ = event_sender.send(WorkerEvent::Activity(
"audio worker stopped; unable to play speech".into(),
));
}
}
Err(error) => {
let _ = event_sender.send(WorkerEvent::Activity(format!(
"Kokoro speech failed: {error}"
)));
}
}
let _ = event_sender.send(WorkerEvent::QueueDepth(queued.len()));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
});
control_sender
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn tailer_starts_at_end_and_recovers_after_truncation() {
let directory = tempdir().unwrap();
let path = directory.path().join("eqlog.txt");
fs::write(&path, "old\n").unwrap();
let mut position = None;
assert!(read_new_lines(&path, &mut position).unwrap().is_empty());
fs::write(&path, "old\nnew\n").unwrap();
assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["new"]);
fs::write(&path, "fresh\n").unwrap();
assert_eq!(read_new_lines(&path, &mut position).unwrap(), vec!["fresh"]);
}
}