feat: support custom mp3 notification sound overrides
This commit is contained in:
parent
951fb1d88b
commit
55c138263e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ impl AppState {
|
|||
change.previous_state,
|
||||
change.state,
|
||||
) {
|
||||
crate::sound::play(sound);
|
||||
crate::sound::play(sound, &self.sound);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
150
src/config.rs
150
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,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#"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
58
src/sound.rs
58
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}"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue