//! One audio decision for notification delivery. The caller first applies //! attention, duration, method, quiet and category gates. This policy selects //! one cue and preserves per-category repeat history across settings updates. use super::payload::NotificationKind; pub use codewhale_config::notifications::NotificationEvent as SoundEvent; use codewhale_config::notifications::{CompletionSound, NotificationsConfig}; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; pub fn event_for_kind(kind: NotificationKind) -> SoundEvent { match kind { NotificationKind::TurnComplete => SoundEvent::TurnComplete, NotificationKind::SubagentTerminal | NotificationKind::BackgroundTerminal => { SoundEvent::SubagentTerminal } NotificationKind::ApprovalNeeded => SoundEvent::ApprovalNeeded, NotificationKind::InputNeeded => SoundEvent::InputNeeded, NotificationKind::ElevationNeeded => SoundEvent::ElevationNeeded, NotificationKind::ModelNotify => SoundEvent::ModelNotify, } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SoundCue { Bell, DoubleBell, Beep, Whale, File(PathBuf), } pub fn cue_for(event: SoundEvent) -> SoundCue { match event { SoundEvent::TurnComplete | SoundEvent::SubagentTerminal => SoundCue::Bell, SoundEvent::ApprovalNeeded | SoundEvent::ElevationNeeded => SoundCue::DoubleBell, SoundEvent::InputNeeded | SoundEvent::ModelNotify => SoundCue::Beep, } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SuppressReason { Disabled, QuietMode, NotListed, RateLimited, MissingFile, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SoundDecision { Play(SoundCue), Suppress(SuppressReason), } #[derive(Debug, Clone, Default)] pub struct EventSoundPolicy { config: NotificationsConfig, last_played_ms: [Option; 6], } impl EventSoundPolicy { pub fn from_config(config: &NotificationsConfig) -> Self { Self { config: config.clone(), last_played_ms: [None; 6], } } pub fn decide( &mut self, event: SoundEvent, now_ms: u64, bell_transport: bool, ) -> SoundDecision { Self::decide_configured( &self.config, &mut self.last_played_ms, event, now_ms, bell_transport, ) } fn decide_configured( config: &NotificationsConfig, last_played_ms: &mut [Option; 6], event: SoundEvent, now_ms: u64, bell_transport: bool, ) -> SoundDecision { if config.quiet { return SoundDecision::Suppress(SuppressReason::QuietMode); } let selected = if let Some(sound) = config.sound { Some(sound) } else if event == SoundEvent::TurnComplete && config.completion_sound != CompletionSound::Off { Some(config.completion_sound) } else { if config.event_sound.quiet { return SoundDecision::Suppress(SuppressReason::QuietMode); } if config.event_sound.enabled { if !config .event_sound .events .iter() .any(|name| SoundEvent::parse(name) == Some(event)) { return SoundDecision::Suppress(SuppressReason::NotListed); } None } else if bell_transport { Some(CompletionSound::Bell) } else { return SoundDecision::Suppress(SuppressReason::Disabled); } }; let cue = match selected { Some(CompletionSound::Off) => return SoundDecision::Suppress(SuppressReason::Disabled), Some(CompletionSound::Whale) => SoundCue::Whale, Some(CompletionSound::Bell) => SoundCue::Bell, Some(CompletionSound::Beep) => SoundCue::Beep, Some(CompletionSound::File) => match &config.sound_file { Some(path) => SoundCue::File(path.clone()), None => return SoundDecision::Suppress(SuppressReason::MissingFile), }, None => cue_for(event), }; let slot = &mut last_played_ms[event.index()]; if slot.is_some_and(|last| now_ms.saturating_sub(last) < config.event_sound.min_interval_ms) { return SoundDecision::Suppress(SuppressReason::RateLimited); } *slot = Some(now_ms); SoundDecision::Play(cue) } } pub fn epoch_millis_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis().min(u64::MAX as u128) as u64) .unwrap_or(0) } static POLICY: OnceLock> = OnceLock::new(); fn policy_cell() -> &'static RwLock { POLICY.get_or_init(|| RwLock::new(EventSoundPolicy::default())) } pub fn reconfigure(mut policy: EventSoundPolicy) { if let Ok(mut slot) = policy_cell().write() { policy.last_played_ms = slot.last_played_ms; *slot = policy; } } pub fn decide(kind: NotificationKind, now_ms: u64, bell_transport: bool) -> SoundDecision { policy_cell() .write() .map(|mut policy| policy.decide(event_for_kind(kind), now_ms, bell_transport)) .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled)) } /// Give back a repeat slot that `decide` reserved for a cue that did not play. /// Only the exact reservation is released, so a later decision in the same /// category keeps its own slot. pub fn release(kind: NotificationKind, reserved_ms: u64) { if let Ok(mut policy) = policy_cell().write() { let slot = &mut policy.last_played_ms[event_for_kind(kind).index()]; if *slot == Some(reserved_ms) { *slot = None; } } } /// Decide from this request's configuration while holding the shared history /// lock. Request snapshots never replace the installed TUI/model policy. pub fn decide_configured( config: &NotificationsConfig, kind: NotificationKind, now_ms: u64, bell_transport: bool, ) -> SoundDecision { policy_cell() .write() .map(|mut policy| { EventSoundPolicy::decide_configured( config, &mut policy.last_played_ms, event_for_kind(kind), now_ms, bell_transport, ) }) .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled)) } #[cfg(any(test, feature = "test-support"))] pub fn configure(policy: EventSoundPolicy) { *policy_cell().write().unwrap() = policy; } #[cfg(test)] mod tests { use super::*; use codewhale_config::notifications::EventSoundConfig; #[test] fn canonical_whale_controls_all_six_categories_over_legacy_controls() { let config = NotificationsConfig { sound: Some(CompletionSound::Whale), completion_sound: CompletionSound::Bell, event_sound: EventSoundConfig { quiet: true, ..Default::default() }, ..Default::default() }; let mut policy = EventSoundPolicy::from_config(&config); for event in SoundEvent::ALL { assert_eq!( policy.decide(event, 0, false), SoundDecision::Play(SoundCue::Whale) ); } } #[test] fn explicit_off_silences_legacy_sounds_and_bell_transport() { let config = NotificationsConfig { sound: Some(CompletionSound::Off), completion_sound: CompletionSound::Whale, event_sound: EventSoundConfig { enabled: true, ..Default::default() }, ..Default::default() }; let mut policy = EventSoundPolicy::from_config(&config); for event in SoundEvent::ALL { assert_eq!( policy.decide(event, 0, true), SoundDecision::Suppress(SuppressReason::Disabled) ); } } #[test] fn legacy_completion_uses_same_decision_and_other_categories_keep_allowlist() { let mut policy = EventSoundPolicy::from_config(&NotificationsConfig { completion_sound: CompletionSound::Whale, event_sound: EventSoundConfig { enabled: true, ..Default::default() }, ..Default::default() }); assert_eq!( policy.decide(SoundEvent::TurnComplete, 0, false), SoundDecision::Play(SoundCue::Whale) ); assert_eq!( policy.decide(SoundEvent::ApprovalNeeded, 0, false), SoundDecision::Play(SoundCue::DoubleBell) ); assert_eq!( policy.decide(SoundEvent::InputNeeded, 0, false), SoundDecision::Suppress(SuppressReason::NotListed) ); } #[test] fn default_sound_is_opt_in_but_explicit_bell_transport_selects_one_bell() { let mut policy = EventSoundPolicy::default(); assert_eq!( policy.decide(SoundEvent::TurnComplete, 0, false), SoundDecision::Suppress(SuppressReason::Disabled) ); assert_eq!( policy.decide(SoundEvent::TurnComplete, 0, true), SoundDecision::Play(SoundCue::Bell) ); } #[test] fn rate_limit_is_per_category_handles_clock_reversal_and_exact_boundary() { let mut policy = EventSoundPolicy::from_config(&NotificationsConfig { sound: Some(CompletionSound::Whale), ..Default::default() }); assert_eq!( policy.decide(SoundEvent::TurnComplete, 5000, false), SoundDecision::Play(SoundCue::Whale) ); for now in [1000, 5001, 6999] { assert_eq!( policy.decide(SoundEvent::TurnComplete, now, false), SoundDecision::Suppress(SuppressReason::RateLimited) ); } assert_eq!( policy.decide(SoundEvent::ApprovalNeeded, 5001, false), SoundDecision::Play(SoundCue::Whale) ); assert_eq!( policy.decide(SoundEvent::TurnComplete, 7000, false), SoundDecision::Play(SoundCue::Whale) ); } #[test] fn settings_refresh_preserves_repeat_history() { let _lock = codewhale_runtime::test_support::lock_test_env(); let config = NotificationsConfig { sound: Some(CompletionSound::Whale), ..Default::default() }; configure(EventSoundPolicy::from_config(&config)); assert_eq!( decide(NotificationKind::ApprovalNeeded, 10, false), SoundDecision::Play(SoundCue::Whale) ); reconfigure(EventSoundPolicy::from_config(&config)); assert_eq!( decide(NotificationKind::ApprovalNeeded, 11, false), SoundDecision::Suppress(SuppressReason::RateLimited) ); configure(EventSoundPolicy::default()); } #[test] fn configured_off_decision_ignores_intervening_installed_whale_policy() { let _lock = codewhale_runtime::test_support::lock_test_env(); let off = NotificationsConfig { sound: Some(CompletionSound::Off), ..Default::default() }; configure(EventSoundPolicy::from_config(&off)); let (ready_tx, ready_rx) = std::sync::mpsc::channel(); let (resume_tx, resume_rx) = std::sync::mpsc::channel(); let request = std::thread::spawn(move || { ready_tx.send(()).unwrap(); resume_rx.recv().unwrap(); decide_configured(&off, NotificationKind::ApprovalNeeded, 10, false) }); ready_rx.recv().unwrap(); reconfigure(EventSoundPolicy::from_config(&NotificationsConfig { sound: Some(CompletionSound::Whale), ..Default::default() })); resume_tx.send(()).unwrap(); assert_eq!( request.join().unwrap(), SoundDecision::Suppress(SuppressReason::Disabled) ); assert_eq!( decide(NotificationKind::ApprovalNeeded, 10, false), SoundDecision::Play(SoundCue::Whale), "the suppressed request neither replaces defaults nor consumes history" ); configure(EventSoundPolicy::default()); } #[test] fn configured_sound_keeps_installed_defaults_and_suppression_keeps_history() { let _lock = codewhale_runtime::test_support::lock_test_env(); let off = NotificationsConfig { sound: Some(CompletionSound::Off), ..Default::default() }; let whale = NotificationsConfig { sound: Some(CompletionSound::Whale), ..Default::default() }; configure(EventSoundPolicy::from_config(&off)); assert_eq!( decide_configured(&whale, NotificationKind::InputNeeded, 100, false), SoundDecision::Play(SoundCue::Whale) ); for config in [ off, NotificationsConfig { quiet: true, ..whale.clone() }, ] { assert!(matches!( decide_configured(&config, NotificationKind::InputNeeded, 101, false), SoundDecision::Suppress(_) )); } assert_eq!( decide_configured(&whale, NotificationKind::InputNeeded, 102, false), SoundDecision::Suppress(SuppressReason::RateLimited) ); assert_eq!( decide(NotificationKind::InputNeeded, 2_100, false), SoundDecision::Suppress(SuppressReason::Disabled), "a request must not replace installed defaults, even after cooldown" ); assert_eq!( decide_configured(&whale, NotificationKind::InputNeeded, 2_100, false), SoundDecision::Play(SoundCue::Whale), "suppression must not move the original exact cooldown boundary" ); configure(EventSoundPolicy::default()); } #[test] fn concurrent_configured_decisions_share_history_with_settings_refresh() { let _lock = codewhale_runtime::test_support::lock_test_env(); configure(EventSoundPolicy::default()); let whale = NotificationsConfig { sound: Some(CompletionSound::Whale), ..Default::default() }; let start = std::sync::Arc::new(std::sync::Barrier::new(3)); let requests = (0..2) .map(|_| { let start = start.clone(); let config = whale.clone(); std::thread::spawn(move || { start.wait(); decide_configured(&config, NotificationKind::ApprovalNeeded, 10, false) }) }) .collect::>(); start.wait(); let results = requests .into_iter() .map(|request| request.join().unwrap()) .collect::>(); assert_eq!( results .iter() .filter(|result| **result == SoundDecision::Play(SoundCue::Whale)) .count(), 1 ); assert_eq!( results .iter() .filter(|result| **result == SoundDecision::Suppress(SuppressReason::RateLimited)) .count(), 1 ); reconfigure(EventSoundPolicy::from_config(&whale)); assert_eq!( decide(NotificationKind::ApprovalNeeded, 11, false), SoundDecision::Suppress(SuppressReason::RateLimited) ); assert_eq!( decide_configured(&whale, NotificationKind::InputNeeded, 11, false), SoundDecision::Play(SoundCue::Whale) ); assert_eq!( decide_configured(&whale, NotificationKind::ApprovalNeeded, 2_010, false), SoundDecision::Play(SoundCue::Whale) ); configure(EventSoundPolicy::default()); } #[test] fn custom_file_requires_path_and_never_substitutes_bell() { let mut config = NotificationsConfig { sound: Some(CompletionSound::File), ..Default::default() }; assert_eq!( EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true), SoundDecision::Suppress(SuppressReason::MissingFile) ); config.sound_file = Some(PathBuf::from("file with spaces.wav")); assert_eq!( EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true), SoundDecision::Play(SoundCue::File(PathBuf::from("file with spaces.wav"))) ); } #[test] fn master_quiet_suppresses_every_sound_and_does_not_consume_repeat_slot() { let mut policy = EventSoundPolicy::from_config(&NotificationsConfig { sound: Some(CompletionSound::Whale), quiet: true, ..Default::default() }); for event in SoundEvent::ALL { assert_eq!( policy.decide(event, 0, true), SoundDecision::Suppress(SuppressReason::QuietMode) ); } policy.config.quiet = false; assert_eq!( policy.decide(SoundEvent::TurnComplete, 1, false), SoundDecision::Play(SoundCue::Whale) ); } #[test] fn shared_event_vocabulary_maps_every_payload_kind() { let kinds = [ NotificationKind::TurnComplete, NotificationKind::SubagentTerminal, NotificationKind::ApprovalNeeded, NotificationKind::InputNeeded, NotificationKind::ElevationNeeded, NotificationKind::ModelNotify, ]; assert_eq!(kinds.map(event_for_kind), SoundEvent::ALL); assert_eq!( event_for_kind(NotificationKind::BackgroundTerminal), SoundEvent::SubagentTerminal ); for event in SoundEvent::ALL { assert_eq!(SoundEvent::parse(event.as_str()), Some(event)); } assert_eq!(SoundEvent::parse("unknown"), None); } }