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