diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-04 02:57:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-04 02:57:52 +0300 |
| commit | 55c138263e190a1e79d4b997bfb9397306c0ec1f (patch) | |
| tree | 440e1cca113be67132a6617b4282dc5c1147950d | |
| parent | 951fb1d88be721461783a239c152cf143d741dc4 (diff) | |
feat: support custom mp3 notification sound overrides
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | src/app/actions.rs | 2 | ||||
| -rw-r--r-- | src/config.rs | 150 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/sound.rs | 58 |
5 files changed, 201 insertions, 16 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index f02213b5..a369c677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added configurable custom mp3 notification sound paths under `[ui.sound]`, with support for one shared file or separate files for finished vs needs-attention alerts. Relative paths are resolved from the config file's directory, and missing/unsupported custom files fall back to the built-in sounds. + ## [0.3.2] - 2026-04-03 ### Changed diff --git a/src/app/actions.rs b/src/app/actions.rs index 41db6f1f..777245da 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -445,7 +445,7 @@ impl AppState { change.previous_state, change.state, ) { - crate::sound::play(sound); + crate::sound::play(sound, &self.sound); } } diff --git a/src/config.rs b/src/config.rs index d5417421..8733d5cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; @@ -162,6 +162,15 @@ pub struct AdvancedConfig { #[serde(default)] pub struct SoundConfig { pub enabled: bool, + /// Optional mp3 file path used for all notification sounds. + /// Relative paths are resolved from the config file's directory. + pub path: Option<PathBuf>, + /// Optional mp3 file path for "done" notifications. + /// Relative paths are resolved from the config file's directory. + pub done_path: Option<PathBuf>, + /// Optional mp3 file path for "request" notifications. + /// Relative paths are resolved from the config file's directory. + pub request_path: Option<PathBuf>, pub agents: AgentSoundOverrides, } @@ -198,6 +207,57 @@ impl SoundConfig { !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) } + + pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> { + let path = match sound { + crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()), + crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()), + }?; + + Some(resolve_config_relative_path(path)) + } + + pub fn diagnostics(&self) -> Vec<String> { + let mut diagnostics = Vec::new(); + for (field, path) in [ + ("ui.sound.path", self.path.as_ref()), + ("ui.sound.done_path", self.done_path.as_ref()), + ("ui.sound.request_path", self.request_path.as_ref()), + ] { + let Some(path) = path else { + continue; + }; + + let resolved = resolve_config_relative_path(path); + if resolved + .extension() + .and_then(|ext| ext.to_str()) + .is_none_or(|ext| !ext.eq_ignore_ascii_case("mp3")) + { + diagnostics.push(format!( + "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound", + path.display(), + resolved.display() + )); + continue; + } + + if !resolved.exists() { + diagnostics.push(format!( + "missing sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } else if !resolved.is_file() { + diagnostics.push(format!( + "invalid sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } + } + diagnostics + } } impl AgentSoundOverrides { @@ -269,6 +329,9 @@ impl Default for SoundConfig { fn default() -> Self { Self { enabled: true, + path: None, + done_path: None, + request_path: None, agents: AgentSoundOverrides::default(), } } @@ -343,9 +406,26 @@ impl Config { pub fn collect_diagnostics(&self) -> Vec<String> { let (prefix_diag, _, keybind_diags, _) = self.validated_keybinds(); - prefix_diag.into_iter().chain(keybind_diags).collect() + prefix_diag + .into_iter() + .chain(keybind_diags) + .chain(self.ui.sound.diagnostics()) + .collect() + } +} + +fn resolve_config_relative_path(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); } + config_path() + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) +} + +impl Config { fn validated_keybinds( &self, ) -> ( @@ -1197,6 +1277,9 @@ rename_workspace = "g" let toml = r#" [ui.sound] enabled = true +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +request_path = "/tmp/request.mp3" [ui.sound.agents] droid = "off" @@ -1204,12 +1287,75 @@ claude = "on" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.ui.sound.enabled); + assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3"))); + assert_eq!( + config.ui.sound.done_path, + Some(PathBuf::from("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.request_path, + Some(PathBuf::from("/tmp/request.mp3")) + ); assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); } #[test] + fn sound_path_resolution_prefers_specific_over_global() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +"#, + ) + .unwrap(); + + let config_root = config_path().parent().unwrap().to_path_buf(); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Done), + Some(config_root.join("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Request), + Some(config_root.join("sounds/all.mp3")) + ); + } + + #[test] + fn missing_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +done_path = "sounds/missing.mp3" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any( + |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound") + )); + } + + #[test] + fn non_mp3_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/notification.wav" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any(|diag| { + diag.contains("ui.sound.path") && diag.contains("expected an mp3 file") + })); + } + + #[test] fn advanced_allow_nested_parses() { let toml = r#" [advanced] diff --git a/src/main.rs b/src/main.rs index ee72e49b..170edcfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Play sounds when agents change state in background workspaces [ui.sound] # enabled = true +# Optional custom mp3 sound files. Relative paths are resolved from this config file's directory. +# path = "sounds/notification.mp3" # one mp3 file for all sound notifications +# done_path = "sounds/done.mp3" # overrides only finished notifications +# request_path = "sounds/request.mp3" # overrides only needs-attention notifications # Per-agent overrides: default | on | off # By default, droid is muted. diff --git a/src/sound.rs b/src/sound.rs index aa6fffe6..a156349b 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -4,7 +4,10 @@ //! Uses afplay (macOS) or paplay/aplay (Linux) — no Rust audio dependencies. use std::io::Write; -use std::process::Command; +use std::path::Path; +use std::process::{Command, Output}; + +use tracing::warn; static SOUND_DONE: &[u8] = include_bytes!("../assets/sounds/done.mp3"); static SOUND_REQUEST: &[u8] = include_bytes!("../assets/sounds/request.mp3"); @@ -20,16 +23,37 @@ pub enum Sound { /// Play a notification sound in a background thread. /// Silently does nothing if no audio player is available. -pub fn play(sound: Sound) { +pub fn play(sound: Sound, config: &crate::config::SoundConfig) { + let custom_path = config.path_for(sound); std::thread::spawn(move || { + if let Some(path) = custom_path { + match play_file(&path) { + Ok(()) => return, + Err(err) => { + warn!(path = %path.display(), sound = ?sound, err = %err, "custom sound playback failed, falling back to built-in sound") + } + } + } + let data = match sound { Sound::Done => SOUND_DONE, Sound::Request => SOUND_REQUEST, }; - let _ = play_bytes(data); + + if let Err(err) = play_bytes(data) { + warn!(sound = ?sound, err = %err, "sound playback failed"); + } }); } +fn play_file(path: &Path) -> Result<(), String> { + match run_player(path) { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(format!("player exited with {}", output.status)), + Err(err) => Err(err), + } +} + fn play_bytes(data: &[u8]) -> Result<(), String> { // Write to a temp file (audio players need a file path) let tmp = std::env::temp_dir().join(format!("herdr-sound-{}.mp3", std::process::id())); @@ -37,21 +61,29 @@ fn play_bytes(data: &[u8]) -> Result<(), String> { file.write_all(data).map_err(|e| e.to_string())?; drop(file); - let result = if cfg!(target_os = "macos") { - Command::new("afplay").arg(&tmp).output() - } else { - // Try paplay (PulseAudio) first, fall back to aplay (ALSA) - Command::new("paplay") - .arg(&tmp) - .output() - .or_else(|_| Command::new("aplay").arg(&tmp).output()) - }; + let result = run_player(&tmp); let _ = std::fs::remove_file(&tmp); match result { Ok(output) if output.status.success() => Ok(()), Ok(output) => Err(format!("player exited with {}", output.status)), - Err(e) => Err(format!("no audio player available: {e}")), + Err(e) => Err(e), + } +} + +fn run_player(path: &Path) -> Result<Output, String> { + if cfg!(target_os = "macos") { + Command::new("afplay") + .arg(path) + .output() + .map_err(|e| format!("no audio player available: {e}")) + } else { + // Try paplay (PulseAudio) first, fall back to aplay (ALSA) + Command::new("paplay") + .arg(path) + .output() + .or_else(|_| Command::new("aplay").arg(path).output()) + .map_err(|e| format!("no audio player available: {e}")) } } |
