feat: support custom mp3 notification sound overrides

This commit is contained in:
Ogulcan Celik 2026-04-04 02:57:52 +03:00
parent 951fb1d88b
commit 55c138263e
5 changed files with 201 additions and 16 deletions

View File

@ -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

View File

@ -445,7 +445,7 @@ impl AppState {
change.previous_state,
change.state,
) {
crate::sound::play(sound);
crate::sound::play(sound, &self.sound);
}
}

View File

@ -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,11 +1287,74 @@ 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#"

View File

@ -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.

View File

@ -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}"))
}
}