返回 CodeWhale
sound_policy.rs
根目录 / crates / tui / src / notify / sound_policy.rs
1 //! One audio decision for notification delivery. The caller first applies
2 //! attention, duration, method, quiet and category gates. This policy selects
3 //! one cue and preserves per-category repeat history across settings updates.
4
5 use super::payload::NotificationKind;
6 use crate::config::{CompletionSound, NotificationsConfig};
7 pub use codewhale_config::notifications::NotificationEvent as SoundEvent;
8 use std::path::PathBuf;
9 use std::sync::{OnceLock, RwLock};
10
11 pub fn event_for_kind(kind: NotificationKind) -> SoundEvent {
12 match kind {
13 NotificationKind::TurnComplete => SoundEvent::TurnComplete,
14 NotificationKind::SubagentTerminal | NotificationKind::BackgroundTerminal => {
15 SoundEvent::SubagentTerminal
16 }
17 NotificationKind::ApprovalNeeded => SoundEvent::ApprovalNeeded,
18 NotificationKind::InputNeeded => SoundEvent::InputNeeded,
19 NotificationKind::ElevationNeeded => SoundEvent::ElevationNeeded,
20 NotificationKind::ModelNotify => SoundEvent::ModelNotify,
21 }
22 }
23
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 pub enum SoundCue {
26 Bell,
27 DoubleBell,
28 Beep,
29 Whale,
30 File(PathBuf),
31 }
32
33 pub fn cue_for(event: SoundEvent) -> SoundCue {
34 match event {
35 SoundEvent::TurnComplete | SoundEvent::SubagentTerminal => SoundCue::Bell,
36 SoundEvent::ApprovalNeeded | SoundEvent::ElevationNeeded => SoundCue::DoubleBell,
37 SoundEvent::InputNeeded | SoundEvent::ModelNotify => SoundCue::Beep,
38 }
39 }
40
41 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 pub enum SuppressReason {
43 Disabled,
44 QuietMode,
45 NotListed,
46 RateLimited,
47 MissingFile,
48 }
49
50 #[derive(Debug, Clone, PartialEq, Eq)]
51 pub enum SoundDecision {
52 Play(SoundCue),
53 Suppress(SuppressReason),
54 }
55
56 #[derive(Debug, Clone, Default)]
57 pub struct EventSoundPolicy {
58 config: NotificationsConfig,
59 last_played_ms: [Option<u64>; 6],
60 }
61
62 impl EventSoundPolicy {
63 pub fn from_config(config: &NotificationsConfig) -> Self {
64 Self {
65 config: config.clone(),
66 last_played_ms: [None; 6],
67 }
68 }
69
70 pub fn decide(
71 &mut self,
72 event: SoundEvent,
73 now_ms: u64,
74 bell_transport: bool,
75 ) -> SoundDecision {
76 Self::decide_configured(
77 &self.config,
78 &mut self.last_played_ms,
79 event,
80 now_ms,
81 bell_transport,
82 )
83 }
84
85 fn decide_configured(
86 config: &NotificationsConfig,
87 last_played_ms: &mut [Option<u64>; 6],
88 event: SoundEvent,
89 now_ms: u64,
90 bell_transport: bool,
91 ) -> SoundDecision {
92 if config.quiet {
93 return SoundDecision::Suppress(SuppressReason::QuietMode);
94 }
95 let selected = if let Some(sound) = config.sound {
96 Some(sound)
97 } else if event == SoundEvent::TurnComplete
98 && config.completion_sound != CompletionSound::Off
99 {
100 Some(config.completion_sound)
101 } else {
102 if config.event_sound.quiet {
103 return SoundDecision::Suppress(SuppressReason::QuietMode);
104 }
105 if config.event_sound.enabled {
106 if !config
107 .event_sound
108 .events
109 .iter()
110 .any(|name| SoundEvent::parse(name) == Some(event))
111 {
112 return SoundDecision::Suppress(SuppressReason::NotListed);
113 }
114 None
115 } else if bell_transport {
116 Some(CompletionSound::Bell)
117 } else {
118 return SoundDecision::Suppress(SuppressReason::Disabled);
119 }
120 };
121 let cue = match selected {
122 Some(CompletionSound::Off) => return SoundDecision::Suppress(SuppressReason::Disabled),
123 Some(CompletionSound::Whale) => SoundCue::Whale,
124 Some(CompletionSound::Bell) => SoundCue::Bell,
125 Some(CompletionSound::Beep) => SoundCue::Beep,
126 Some(CompletionSound::File) => match &config.sound_file {
127 Some(path) => SoundCue::File(path.clone()),
128 None => return SoundDecision::Suppress(SuppressReason::MissingFile),
129 },
130 None => cue_for(event),
131 };
132 let slot = &mut last_played_ms[event.index()];
133 if slot.is_some_and(|last| now_ms.saturating_sub(last) < config.event_sound.min_interval_ms)
134 {
135 return SoundDecision::Suppress(SuppressReason::RateLimited);
136 }
137 *slot = Some(now_ms);
138 SoundDecision::Play(cue)
139 }
140 }
141
142 pub fn epoch_millis_now() -> u64 {
143 std::time::SystemTime::now()
144 .duration_since(std::time::UNIX_EPOCH)
145 .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
146 .unwrap_or(0)
147 }
148
149 static POLICY: OnceLock<RwLock<EventSoundPolicy>> = OnceLock::new();
150 fn policy_cell() -> &'static RwLock<EventSoundPolicy> {
151 POLICY.get_or_init(|| RwLock::new(EventSoundPolicy::default()))
152 }
153
154 pub fn reconfigure(mut policy: EventSoundPolicy) {
155 if let Ok(mut slot) = policy_cell().write() {
156 policy.last_played_ms = slot.last_played_ms;
157 *slot = policy;
158 }
159 }
160
161 pub fn decide(kind: NotificationKind, now_ms: u64, bell_transport: bool) -> SoundDecision {
162 policy_cell()
163 .write()
164 .map(|mut policy| policy.decide(event_for_kind(kind), now_ms, bell_transport))
165 .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
166 }
167
168 /// Give back a repeat slot that `decide` reserved for a cue that did not play.
169 /// Only the exact reservation is released, so a later decision in the same
170 /// category keeps its own slot.
171 pub fn release(kind: NotificationKind, reserved_ms: u64) {
172 if let Ok(mut policy) = policy_cell().write() {
173 let slot = &mut policy.last_played_ms[event_for_kind(kind).index()];
174 if *slot == Some(reserved_ms) {
175 *slot = None;
176 }
177 }
178 }
179
180 /// Decide from this request's configuration while holding the shared history
181 /// lock. Request snapshots never replace the installed TUI/model policy.
182 pub fn decide_configured(
183 config: &NotificationsConfig,
184 kind: NotificationKind,
185 now_ms: u64,
186 bell_transport: bool,
187 ) -> SoundDecision {
188 policy_cell()
189 .write()
190 .map(|mut policy| {
191 EventSoundPolicy::decide_configured(
192 config,
193 &mut policy.last_played_ms,
194 event_for_kind(kind),
195 now_ms,
196 bell_transport,
197 )
198 })
199 .unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
200 }
201
202 #[cfg(test)]
203 pub fn configure(policy: EventSoundPolicy) {
204 *policy_cell().write().unwrap() = policy;
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210 use crate::config::EventSoundConfig;
211
212 #[test]
213 fn canonical_whale_controls_all_six_categories_over_legacy_controls() {
214 let config = NotificationsConfig {
215 sound: Some(CompletionSound::Whale),
216 completion_sound: CompletionSound::Bell,
217 event_sound: EventSoundConfig {
218 quiet: true,
219 ..Default::default()
220 },
221 ..Default::default()
222 };
223 let mut policy = EventSoundPolicy::from_config(&config);
224 for event in SoundEvent::ALL {
225 assert_eq!(
226 policy.decide(event, 0, false),
227 SoundDecision::Play(SoundCue::Whale)
228 );
229 }
230 }
231
232 #[test]
233 fn explicit_off_silences_legacy_sounds_and_bell_transport() {
234 let config = NotificationsConfig {
235 sound: Some(CompletionSound::Off),
236 completion_sound: CompletionSound::Whale,
237 event_sound: EventSoundConfig {
238 enabled: true,
239 ..Default::default()
240 },
241 ..Default::default()
242 };
243 let mut policy = EventSoundPolicy::from_config(&config);
244 for event in SoundEvent::ALL {
245 assert_eq!(
246 policy.decide(event, 0, true),
247 SoundDecision::Suppress(SuppressReason::Disabled)
248 );
249 }
250 }
251
252 #[test]
253 fn legacy_completion_uses_same_decision_and_other_categories_keep_allowlist() {
254 let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
255 completion_sound: CompletionSound::Whale,
256 event_sound: EventSoundConfig {
257 enabled: true,
258 ..Default::default()
259 },
260 ..Default::default()
261 });
262 assert_eq!(
263 policy.decide(SoundEvent::TurnComplete, 0, false),
264 SoundDecision::Play(SoundCue::Whale)
265 );
266 assert_eq!(
267 policy.decide(SoundEvent::ApprovalNeeded, 0, false),
268 SoundDecision::Play(SoundCue::DoubleBell)
269 );
270 assert_eq!(
271 policy.decide(SoundEvent::InputNeeded, 0, false),
272 SoundDecision::Suppress(SuppressReason::NotListed)
273 );
274 }
275
276 #[test]
277 fn default_sound_is_opt_in_but_explicit_bell_transport_selects_one_bell() {
278 let mut policy = EventSoundPolicy::default();
279 assert_eq!(
280 policy.decide(SoundEvent::TurnComplete, 0, false),
281 SoundDecision::Suppress(SuppressReason::Disabled)
282 );
283 assert_eq!(
284 policy.decide(SoundEvent::TurnComplete, 0, true),
285 SoundDecision::Play(SoundCue::Bell)
286 );
287 }
288
289 #[test]
290 fn rate_limit_is_per_category_handles_clock_reversal_and_exact_boundary() {
291 let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
292 sound: Some(CompletionSound::Whale),
293 ..Default::default()
294 });
295 assert_eq!(
296 policy.decide(SoundEvent::TurnComplete, 5000, false),
297 SoundDecision::Play(SoundCue::Whale)
298 );
299 for now in [1000, 5001, 6999] {
300 assert_eq!(
301 policy.decide(SoundEvent::TurnComplete, now, false),
302 SoundDecision::Suppress(SuppressReason::RateLimited)
303 );
304 }
305 assert_eq!(
306 policy.decide(SoundEvent::ApprovalNeeded, 5001, false),
307 SoundDecision::Play(SoundCue::Whale)
308 );
309 assert_eq!(
310 policy.decide(SoundEvent::TurnComplete, 7000, false),
311 SoundDecision::Play(SoundCue::Whale)
312 );
313 }
314
315 #[test]
316 fn settings_refresh_preserves_repeat_history() {
317 let _lock = crate::test_support::lock_test_env();
318 let config = NotificationsConfig {
319 sound: Some(CompletionSound::Whale),
320 ..Default::default()
321 };
322 configure(EventSoundPolicy::from_config(&config));
323 assert_eq!(
324 decide(NotificationKind::ApprovalNeeded, 10, false),
325 SoundDecision::Play(SoundCue::Whale)
326 );
327 reconfigure(EventSoundPolicy::from_config(&config));
328 assert_eq!(
329 decide(NotificationKind::ApprovalNeeded, 11, false),
330 SoundDecision::Suppress(SuppressReason::RateLimited)
331 );
332 configure(EventSoundPolicy::default());
333 }
334
335 #[test]
336 fn configured_off_decision_ignores_intervening_installed_whale_policy() {
337 let _lock = crate::test_support::lock_test_env();
338 let off = NotificationsConfig {
339 sound: Some(CompletionSound::Off),
340 ..Default::default()
341 };
342 configure(EventSoundPolicy::from_config(&off));
343 let (ready_tx, ready_rx) = std::sync::mpsc::channel();
344 let (resume_tx, resume_rx) = std::sync::mpsc::channel();
345 let request = std::thread::spawn(move || {
346 ready_tx.send(()).unwrap();
347 resume_rx.recv().unwrap();
348 decide_configured(&off, NotificationKind::ApprovalNeeded, 10, false)
349 });
350 ready_rx.recv().unwrap();
351 reconfigure(EventSoundPolicy::from_config(&NotificationsConfig {
352 sound: Some(CompletionSound::Whale),
353 ..Default::default()
354 }));
355 resume_tx.send(()).unwrap();
356 assert_eq!(
357 request.join().unwrap(),
358 SoundDecision::Suppress(SuppressReason::Disabled)
359 );
360 assert_eq!(
361 decide(NotificationKind::ApprovalNeeded, 10, false),
362 SoundDecision::Play(SoundCue::Whale),
363 "the suppressed request neither replaces defaults nor consumes history"
364 );
365 configure(EventSoundPolicy::default());
366 }
367
368 #[test]
369 fn configured_sound_keeps_installed_defaults_and_suppression_keeps_history() {
370 let _lock = crate::test_support::lock_test_env();
371 let off = NotificationsConfig {
372 sound: Some(CompletionSound::Off),
373 ..Default::default()
374 };
375 let whale = NotificationsConfig {
376 sound: Some(CompletionSound::Whale),
377 ..Default::default()
378 };
379 configure(EventSoundPolicy::from_config(&off));
380 assert_eq!(
381 decide_configured(&whale, NotificationKind::InputNeeded, 100, false),
382 SoundDecision::Play(SoundCue::Whale)
383 );
384 for config in [
385 off,
386 NotificationsConfig {
387 quiet: true,
388 ..whale.clone()
389 },
390 ] {
391 assert!(matches!(
392 decide_configured(&config, NotificationKind::InputNeeded, 101, false),
393 SoundDecision::Suppress(_)
394 ));
395 }
396 assert_eq!(
397 decide_configured(&whale, NotificationKind::InputNeeded, 102, false),
398 SoundDecision::Suppress(SuppressReason::RateLimited)
399 );
400 assert_eq!(
401 decide(NotificationKind::InputNeeded, 2_100, false),
402 SoundDecision::Suppress(SuppressReason::Disabled),
403 "a request must not replace installed defaults, even after cooldown"
404 );
405 assert_eq!(
406 decide_configured(&whale, NotificationKind::InputNeeded, 2_100, false),
407 SoundDecision::Play(SoundCue::Whale),
408 "suppression must not move the original exact cooldown boundary"
409 );
410 configure(EventSoundPolicy::default());
411 }
412
413 #[test]
414 fn concurrent_configured_decisions_share_history_with_settings_refresh() {
415 let _lock = crate::test_support::lock_test_env();
416 configure(EventSoundPolicy::default());
417 let whale = NotificationsConfig {
418 sound: Some(CompletionSound::Whale),
419 ..Default::default()
420 };
421 let start = std::sync::Arc::new(std::sync::Barrier::new(3));
422 let requests = (0..2)
423 .map(|_| {
424 let start = start.clone();
425 let config = whale.clone();
426 std::thread::spawn(move || {
427 start.wait();
428 decide_configured(&config, NotificationKind::ApprovalNeeded, 10, false)
429 })
430 })
431 .collect::<Vec<_>>();
432 start.wait();
433 let results = requests
434 .into_iter()
435 .map(|request| request.join().unwrap())
436 .collect::<Vec<_>>();
437 assert_eq!(
438 results
439 .iter()
440 .filter(|result| **result == SoundDecision::Play(SoundCue::Whale))
441 .count(),
442 1
443 );
444 assert_eq!(
445 results
446 .iter()
447 .filter(|result| **result == SoundDecision::Suppress(SuppressReason::RateLimited))
448 .count(),
449 1
450 );
451 reconfigure(EventSoundPolicy::from_config(&whale));
452 assert_eq!(
453 decide(NotificationKind::ApprovalNeeded, 11, false),
454 SoundDecision::Suppress(SuppressReason::RateLimited)
455 );
456 assert_eq!(
457 decide_configured(&whale, NotificationKind::InputNeeded, 11, false),
458 SoundDecision::Play(SoundCue::Whale)
459 );
460 assert_eq!(
461 decide_configured(&whale, NotificationKind::ApprovalNeeded, 2_010, false),
462 SoundDecision::Play(SoundCue::Whale)
463 );
464 configure(EventSoundPolicy::default());
465 }
466
467 #[test]
468 fn custom_file_requires_path_and_never_substitutes_bell() {
469 let mut config = NotificationsConfig {
470 sound: Some(CompletionSound::File),
471 ..Default::default()
472 };
473 assert_eq!(
474 EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true),
475 SoundDecision::Suppress(SuppressReason::MissingFile)
476 );
477 config.sound_file = Some(PathBuf::from("file with spaces.wav"));
478 assert_eq!(
479 EventSoundPolicy::from_config(&config).decide(SoundEvent::TurnComplete, 0, true),
480 SoundDecision::Play(SoundCue::File(PathBuf::from("file with spaces.wav")))
481 );
482 }
483
484 #[test]
485 fn master_quiet_suppresses_every_sound_and_does_not_consume_repeat_slot() {
486 let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
487 sound: Some(CompletionSound::Whale),
488 quiet: true,
489 ..Default::default()
490 });
491 for event in SoundEvent::ALL {
492 assert_eq!(
493 policy.decide(event, 0, true),
494 SoundDecision::Suppress(SuppressReason::QuietMode)
495 );
496 }
497 policy.config.quiet = false;
498 assert_eq!(
499 policy.decide(SoundEvent::TurnComplete, 1, false),
500 SoundDecision::Play(SoundCue::Whale)
501 );
502 }
503
504 #[test]
505 fn shared_event_vocabulary_maps_every_payload_kind() {
506 let kinds = [
507 NotificationKind::TurnComplete,
508 NotificationKind::SubagentTerminal,
509 NotificationKind::ApprovalNeeded,
510 NotificationKind::InputNeeded,
511 NotificationKind::ElevationNeeded,
512 NotificationKind::ModelNotify,
513 ];
514 assert_eq!(kinds.map(event_for_kind), SoundEvent::ALL);
515 assert_eq!(
516 event_for_kind(NotificationKind::BackgroundTerminal),
517 SoundEvent::SubagentTerminal
518 );
519 for event in SoundEvent::ALL {
520 assert_eq!(SoundEvent::parse(event.as_str()), Some(event));
521 }
522 assert_eq!(SoundEvent::parse("unknown"), None);
523 }
524 }
525
525 lines RUST