返回 CodeWhale
notification_audio.rs
根目录 / crates / tui / src / tui / notification_audio.rs
1 //! Local audio dispatch. Policy decides the cue; this sink never substitutes a
2 //! bell for a missing/unsupported WAV. Tests inject a sink and never call an OS
3 //! player. A single in-flight WAV keeps repeated categories from stacking audio.
4 use super::sound_policy::SoundCue;
5 #[cfg(not(test))]
6 use std::io;
7 use std::io::Write;
8
9 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 pub enum AudioOutcome {
11 Emitted,
12 Dispatched,
13 Unsupported,
14 Failed,
15 Busy,
16 }
17
18 pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
19 let bytes: &[u8] = match cue {
20 SoundCue::Bell | SoundCue::Beep => b"\x07",
21 SoundCue::DoubleBell => b"\x07\x07",
22 SoundCue::Whale | SoundCue::File(_) => return AudioOutcome::Unsupported,
23 };
24 if out.write_all(bytes).and_then(|()| out.flush()).is_ok() {
25 AudioOutcome::Emitted
26 } else {
27 AudioOutcome::Failed
28 }
29 }
30
31 pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-call.wav");
32
33 #[cfg(not(test))]
34 pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
35 #[cfg(target_os = "windows")]
36 if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
37 use windows::Win32::System::Diagnostics::Debug::MessageBeep;
38 use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
39 let count = if *cue == SoundCue::DoubleBell { 2 } else { 1 };
40 for _ in 0..count {
41 if unsafe { MessageBeep(MESSAGEBOX_STYLE(0)) }.is_err() {
42 return AudioOutcome::Failed;
43 }
44 }
45 return AudioOutcome::Emitted;
46 }
47 if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
48 return emit_terminal(cue, out);
49 }
50 dispatch_wav(cue)
51 }
52
53 #[cfg(test)]
54 pub fn dispatch(_cue: &SoundCue, _out: &mut dyn Write) -> AudioOutcome {
55 // Production entry points are also fail-closed in library tests.
56 AudioOutcome::Unsupported
57 }
58
59 #[cfg(not(test))]
60 static PLAYING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
61
62 #[cfg(not(test))]
63 fn dispatch_wav(cue: &SoundCue) -> AudioOutcome {
64 use std::sync::atomic::Ordering;
65 if !cfg!(any(
66 target_os = "windows",
67 target_os = "macos",
68 target_os = "linux"
69 )) {
70 return AudioOutcome::Unsupported;
71 }
72 if PLAYING
73 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
74 .is_err()
75 {
76 return AudioOutcome::Busy;
77 }
78 let cue = cue.clone();
79 match std::thread::Builder::new()
80 .name("notification-audio".into())
81 .spawn(move || {
82 struct Reset;
83 impl Drop for Reset {
84 fn drop(&mut self) {
85 PLAYING.store(false, Ordering::SeqCst);
86 }
87 }
88 let _reset = Reset;
89 if let Err(error) = play_wav(&cue) {
90 // Do not log file names or external-player output (both may contain private data).
91 tracing::warn!(kind = ?error.kind(), "notification audio playback failed");
92 }
93 }) {
94 Ok(_) => AudioOutcome::Dispatched,
95 Err(_) => {
96 PLAYING.store(false, Ordering::SeqCst);
97 AudioOutcome::Failed
98 }
99 }
100 }
101
102 #[cfg(not(test))]
103 fn play_wav(cue: &SoundCue) -> io::Result<()> {
104 let mut bundled = None;
105 let path = match cue {
106 SoundCue::Whale => {
107 let mut file = tempfile::Builder::new()
108 .prefix("codewhale-call-")
109 .suffix(".wav")
110 .tempfile()?;
111 file.write_all(WHALE_WAV)?;
112 file.flush()?;
113 let path = file.path().to_path_buf();
114 bundled = Some(file);
115 path
116 }
117 SoundCue::File(path) => std::fs::canonicalize(path)?,
118 _ => return Err(io::Error::other("expected WAV cue")),
119 };
120 // Retain the private temporary file until the synchronous player exits.
121 let result = play_file(&path);
122 drop(bundled);
123 result
124 }
125
126 #[cfg(all(not(test), target_os = "windows"))]
127 fn play_file(path: &std::path::Path) -> io::Result<()> {
128 use std::os::windows::ffi::OsStrExt;
129 use windows::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_NODEFAULT};
130 use windows::core::PCWSTR;
131 let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
132 // Synchronous in the worker: the bundled file must outlive playback.
133 if unsafe { PlaySoundW(PCWSTR(wide.as_ptr()), None, SND_FILENAME | SND_NODEFAULT) }.as_bool() {
134 Ok(())
135 } else {
136 Err(io::Error::other("audio player failed"))
137 }
138 }
139
140 #[cfg(all(not(test), any(target_os = "macos", target_os = "linux")))]
141 fn play_file(path: &std::path::Path) -> io::Result<()> {
142 #[cfg(target_os = "macos")]
143 let player = "/usr/bin/afplay";
144 #[cfg(target_os = "linux")]
145 let player = "aplay";
146 let status = std::process::Command::new(player)
147 .arg(path)
148 .stdin(std::process::Stdio::null())
149 .stdout(std::process::Stdio::null())
150 .stderr(std::process::Stdio::null())
151 .status()?;
152 if status.success() {
153 Ok(())
154 } else {
155 Err(io::Error::other("audio player failed"))
156 }
157 }
158
159 #[cfg(all(
160 not(test),
161 not(any(target_os = "windows", target_os = "macos", target_os = "linux"))
162 ))]
163 fn play_file(_path: &std::path::Path) -> io::Result<()> {
164 Err(io::Error::new(
165 io::ErrorKind::Unsupported,
166 "WAV playback unsupported",
167 ))
168 }
169
170 #[cfg(test)]
171 mod tests {
172 use super::*;
173
174 #[test]
175 fn bundled_whale_is_the_complete_pcm_wav_without_clipped_samples() {
176 assert_eq!(&WHALE_WAV[..4], b"RIFF");
177 assert_eq!(&WHALE_WAV[8..12], b"WAVE");
178 assert_eq!(WHALE_WAV.len(), 136754);
179 assert_eq!(u16::from_le_bytes(WHALE_WAV[22..24].try_into().unwrap()), 1);
180 assert_eq!(
181 u32::from_le_bytes(WHALE_WAV[24..28].try_into().unwrap()),
182 44100
183 );
184 assert_eq!(
185 u16::from_le_bytes(WHALE_WAV[34..36].try_into().unwrap()),
186 16
187 );
188 let samples: Vec<i16> = WHALE_WAV[44..]
189 .as_chunks::<2>()
190 .0
191 .iter()
192 .copied()
193 .map(i16::from_le_bytes)
194 .collect();
195 assert_eq!(samples.first(), Some(&0));
196 assert_eq!(samples.last(), Some(&0));
197 assert!(
198 samples
199 .iter()
200 .all(|sample| *sample != i16::MIN && *sample != i16::MAX)
201 );
202 }
203
204 #[test]
205 fn terminal_sink_has_exact_bell_bytes_and_no_wav_fallback() {
206 for (cue, bytes) in [
207 (SoundCue::Bell, &b"\x07"[..]),
208 (SoundCue::Beep, &b"\x07"[..]),
209 (SoundCue::DoubleBell, &b"\x07\x07"[..]),
210 ] {
211 let mut out = Vec::new();
212 assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Emitted);
213 assert_eq!(out, bytes);
214 }
215 for cue in [SoundCue::Whale, SoundCue::File("missing.wav".into())] {
216 let mut out = Vec::new();
217 assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Unsupported);
218 assert!(out.is_empty());
219 }
220 }
221
222 #[test]
223 fn production_audio_entry_is_silent_in_library_tests() {
224 let mut out = Vec::new();
225 assert_eq!(
226 dispatch(&SoundCue::Whale, &mut out),
227 AudioOutcome::Unsupported
228 );
229 assert_eq!(
230 dispatch(&SoundCue::Bell, &mut out),
231 AudioOutcome::Unsupported
232 );
233 assert!(out.is_empty());
234 }
235 }
236
236 lines RUST