aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-04 02:57:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-04 02:57:52 +0300
commit55c138263e190a1e79d4b997bfb9397306c0ec1f (patch)
tree440e1cca113be67132a6617b4282dc5c1147950d /src
parent951fb1d88be721461783a239c152cf143d741dc4 (diff)
feat: support custom mp3 notification sound overrides
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs2
-rw-r--r--src/config.rs150
-rw-r--r--src/main.rs4
-rw-r--r--src/sound.rs58
4 files changed, 198 insertions, 16 deletions
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}"))
}
}