Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16d8b00ba | |||
| ddc49d9ceb |
@@ -63,11 +63,13 @@ running Mudmouth, validation, and multi-platform release packaging.
|
|||||||
|
|
||||||
## Data and privacy
|
## Data and privacy
|
||||||
|
|
||||||
Mudmouth writes only two human-readable files in the operating system's
|
Mudmouth writes only three human-readable files in the operating system's
|
||||||
standard application-data directory:
|
standard application-data directory:
|
||||||
|
|
||||||
- `config.toml` — endpoint, log path, event toggles, and playback settings
|
- `config.toml` — endpoint, log path, event toggles, and playback settings
|
||||||
- `speakers.json` — NPC names, voice modes, and last-seen context
|
- `speakers.json` — NPC names, voice modes, and last-seen context
|
||||||
|
- `filters.toml` — dialogue prefixes to ignore; created with the default
|
||||||
|
ambient-NPC and combat-chatter filters
|
||||||
|
|
||||||
On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform
|
On Linux this is normally `~/.local/share/mudmouth`; the equivalent platform
|
||||||
directory is used on macOS and Windows.
|
directory is used on macOS and Windows.
|
||||||
@@ -79,3 +81,11 @@ repeat_window_seconds = 15
|
|||||||
repeat_second_speed = 1.5
|
repeat_second_speed = 1.5
|
||||||
repeat_third_plus_speed = 2.0
|
repeat_third_plus_speed = 2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To ignore additional repetitive dialogue, edit `filters.toml` and add literal
|
||||||
|
prefixes. For example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
speaker_prefixes = ["A ", "An "]
|
||||||
|
text_prefixes = ["Time to die, ", "Guards! "]
|
||||||
|
```
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ cargo test
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run a test build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop --command cargo run --release --bin mudmouth
|
||||||
|
```
|
||||||
|
|
||||||
## Refreshing the PEQ NPC gender index
|
## Refreshing the PEQ NPC gender index
|
||||||
|
|
||||||
The checked-in `generated/peq-npc-gender-index.json` is generated data, not a
|
The checked-in `generated/peq-npc-gender-index.json` is generated data, not a
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::{
|
||||||
|
config::{Config, atomic_write},
|
||||||
|
events::LogEvent,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// User-managed rules for ignoring repetitive EverQuest dialogue.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DialogueFilters {
|
||||||
|
/// Ignore dialogue whose speaker begins with any of these strings.
|
||||||
|
pub speaker_prefixes: Vec<String>,
|
||||||
|
/// Ignore dialogue whose text begins with any of these strings.
|
||||||
|
pub text_prefixes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DialogueFilters {
|
||||||
|
pub fn path() -> Result<std::path::PathBuf> {
|
||||||
|
Ok(Config::app_dir()?.join("filters.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
let filters = Self::default();
|
||||||
|
atomic_write(&path, toml::to_string_pretty(&filters)?.as_bytes())?;
|
||||||
|
return Ok(filters);
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("could not read {}", path.display()))?;
|
||||||
|
toml::from_str(&contents).context("could not parse dialogue filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ignores(&self, event: &LogEvent) -> bool {
|
||||||
|
let LogEvent::NpcDialogue { speaker, text, .. } = event else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.speaker_prefixes
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| speaker.starts_with(prefix))
|
||||||
|
|| self
|
||||||
|
.text_prefixes
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| text.starts_with(prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DialogueFilters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
speaker_prefixes: vec!["A ".into()],
|
||||||
|
text_prefixes: vec!["Time to die, ".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_match_speaker_and_text_prefixes() {
|
||||||
|
let filters = DialogueFilters::default();
|
||||||
|
let generic_speaker = LogEvent::NpcDialogue {
|
||||||
|
at: Local::now(),
|
||||||
|
speaker: "A froglok guardsman".into(),
|
||||||
|
text: "Halt!".into(),
|
||||||
|
};
|
||||||
|
let combat_chatter = LogEvent::NpcDialogue {
|
||||||
|
at: Local::now(),
|
||||||
|
speaker: "Guard Ralnor".into(),
|
||||||
|
text: "Time to die, a crab spiderling.".into(),
|
||||||
|
};
|
||||||
|
assert!(filters.ignores(&generic_speaker));
|
||||||
|
assert!(filters.ignores(&combat_chatter));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -1,6 +1,7 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod eq_discovery;
|
mod eq_discovery;
|
||||||
mod events;
|
mod events;
|
||||||
|
mod filters;
|
||||||
mod kokoro;
|
mod kokoro;
|
||||||
mod npc_voices;
|
mod npc_voices;
|
||||||
mod speakers;
|
mod speakers;
|
||||||
@@ -16,6 +17,7 @@ use crossterm::{
|
|||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use events::LogEvent;
|
use events::LogEvent;
|
||||||
|
use filters::DialogueFilters;
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use std::{
|
use std::{
|
||||||
io::{self, stdout},
|
io::{self, stdout},
|
||||||
@@ -53,6 +55,7 @@ struct RuntimeChannels {
|
|||||||
worker_control: tokio_mpsc::Sender<Config>,
|
worker_control: tokio_mpsc::Sender<Config>,
|
||||||
audio_control: mpsc::Sender<AudioCommand>,
|
audio_control: mpsc::Sender<AudioCommand>,
|
||||||
tailer_generation: Arc<AtomicUsize>,
|
tailer_generation: Arc<AtomicUsize>,
|
||||||
|
filters: DialogueFilters,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -67,6 +70,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = npc_voices::builtin_index();
|
let _ = npc_voices::builtin_index();
|
||||||
|
let filters = DialogueFilters::load()?;
|
||||||
let speakers = speakers::SpeakerStore::load()?;
|
let speakers = speakers::SpeakerStore::load()?;
|
||||||
let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>();
|
let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>();
|
||||||
let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone());
|
let audio_sender = spawn_audio_worker(config.volume, worker_sender.clone());
|
||||||
@@ -81,7 +85,13 @@ async fn main() -> Result<()> {
|
|||||||
let tailer_generation = Arc::new(AtomicUsize::new(0));
|
let tailer_generation = Arc::new(AtomicUsize::new(0));
|
||||||
if config.log_path.as_ref().is_some_and(|path| path.is_file()) {
|
if config.log_path.as_ref().is_some_and(|path| path.is_file()) {
|
||||||
let path = config.log_path.clone().expect("log path was checked");
|
let path = config.log_path.clone().expect("log path was checked");
|
||||||
spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0);
|
spawn_log_tailer(
|
||||||
|
path,
|
||||||
|
filters.clone(),
|
||||||
|
worker_sender.clone(),
|
||||||
|
tailer_generation.clone(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut app = App::new(config, speakers);
|
let mut app = App::new(config, speakers);
|
||||||
@@ -92,6 +102,7 @@ async fn main() -> Result<()> {
|
|||||||
worker_control,
|
worker_control,
|
||||||
audio_control: audio_sender,
|
audio_control: audio_sender,
|
||||||
tailer_generation,
|
tailer_generation,
|
||||||
|
filters,
|
||||||
};
|
};
|
||||||
run_terminal(&mut app, channels).await
|
run_terminal(&mut app, channels).await
|
||||||
}
|
}
|
||||||
@@ -131,6 +142,7 @@ async fn run_loop(
|
|||||||
if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) {
|
if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) {
|
||||||
spawn_log_tailer(
|
spawn_log_tailer(
|
||||||
path,
|
path,
|
||||||
|
channels.filters.clone(),
|
||||||
channels.worker_sender.clone(),
|
channels.worker_sender.clone(),
|
||||||
channels.tailer_generation.clone(),
|
channels.tailer_generation.clone(),
|
||||||
generation,
|
generation,
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
events::{LogEvent, parse_line},
|
events::{LogEvent, parse_line},
|
||||||
|
filters::DialogueFilters,
|
||||||
kokoro::KokoroClient,
|
kokoro::KokoroClient,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -59,6 +60,7 @@ pub enum AudioCommand {
|
|||||||
|
|
||||||
pub fn spawn_log_tailer(
|
pub fn spawn_log_tailer(
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
filters: DialogueFilters,
|
||||||
sender: Sender<WorkerEvent>,
|
sender: Sender<WorkerEvent>,
|
||||||
generation: Arc<AtomicUsize>,
|
generation: Arc<AtomicUsize>,
|
||||||
expected_generation: usize,
|
expected_generation: usize,
|
||||||
@@ -69,7 +71,9 @@ pub fn spawn_log_tailer(
|
|||||||
match read_new_lines(&path, &mut position) {
|
match read_new_lines(&path, &mut position) {
|
||||||
Ok(lines) => {
|
Ok(lines) => {
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if let Some(event) = parse_line(&line) {
|
if let Some(event) =
|
||||||
|
parse_line(&line).filter(|event| !filters.ignores(event))
|
||||||
|
{
|
||||||
let _ = sender.send(WorkerEvent::Log(event));
|
let _ = sender.send(WorkerEvent::Log(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user