Filter noisy messages
This commit is contained in:
@@ -63,11 +63,13 @@ running Mudmouth, validation, and multi-platform release packaging.
|
||||
|
||||
## 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:
|
||||
|
||||
- `config.toml` — endpoint, log path, event toggles, and playback settings
|
||||
- `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
|
||||
directory is used on macOS and Windows.
|
||||
@@ -79,3 +81,11 @@ repeat_window_seconds = 15
|
||||
repeat_second_speed = 1.5
|
||||
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! "]
|
||||
```
|
||||
|
||||
@@ -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 eq_discovery;
|
||||
mod events;
|
||||
mod filters;
|
||||
mod kokoro;
|
||||
mod npc_voices;
|
||||
mod speakers;
|
||||
@@ -16,6 +17,7 @@ use crossterm::{
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use events::LogEvent;
|
||||
use filters::DialogueFilters;
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use std::{
|
||||
io::{self, stdout},
|
||||
@@ -53,6 +55,7 @@ struct RuntimeChannels {
|
||||
worker_control: tokio_mpsc::Sender<Config>,
|
||||
audio_control: mpsc::Sender<AudioCommand>,
|
||||
tailer_generation: Arc<AtomicUsize>,
|
||||
filters: DialogueFilters,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -67,6 +70,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let _ = npc_voices::builtin_index();
|
||||
let filters = DialogueFilters::load()?;
|
||||
let speakers = speakers::SpeakerStore::load()?;
|
||||
let (worker_sender, worker_receiver) = mpsc::channel::<WorkerEvent>();
|
||||
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));
|
||||
if config.log_path.as_ref().is_some_and(|path| path.is_file()) {
|
||||
let path = config.log_path.clone().expect("log path was checked");
|
||||
spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0);
|
||||
spawn_log_tailer(
|
||||
path,
|
||||
filters.clone(),
|
||||
worker_sender.clone(),
|
||||
tailer_generation.clone(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
let mut app = App::new(config, speakers);
|
||||
@@ -92,6 +102,7 @@ async fn main() -> Result<()> {
|
||||
worker_control,
|
||||
audio_control: audio_sender,
|
||||
tailer_generation,
|
||||
filters,
|
||||
};
|
||||
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()) {
|
||||
spawn_log_tailer(
|
||||
path,
|
||||
channels.filters.clone(),
|
||||
channels.worker_sender.clone(),
|
||||
channels.tailer_generation.clone(),
|
||||
generation,
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
events::{LogEvent, parse_line},
|
||||
filters::DialogueFilters,
|
||||
kokoro::KokoroClient,
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -59,6 +60,7 @@ pub enum AudioCommand {
|
||||
|
||||
pub fn spawn_log_tailer(
|
||||
path: PathBuf,
|
||||
filters: DialogueFilters,
|
||||
sender: Sender<WorkerEvent>,
|
||||
generation: Arc<AtomicUsize>,
|
||||
expected_generation: usize,
|
||||
@@ -69,7 +71,9 @@ pub fn spawn_log_tailer(
|
||||
match read_new_lines(&path, &mut position) {
|
||||
Ok(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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user