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