Initialize repository for MUDMOUTH
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+30
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 EverQuest’s 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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user