| 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 |