par_term/audio_bell.rs
1// ---------------------------------------------------------------------------
2// Full rodio-backed implementation (feature = "audio" enabled)
3// ---------------------------------------------------------------------------
4
5#[cfg(feature = "audio")]
6use parking_lot::Mutex;
7#[cfg(feature = "audio")]
8use rodio::{DeviceSinkBuilder, MixerDeviceSink, Player, Source};
9#[cfg(feature = "audio")]
10use std::sync::Arc;
11#[cfg(feature = "audio")]
12use std::time::Duration;
13
14/// The process-wide audio output, opened lazily on first use.
15///
16/// Opening an output device per pane is both wasteful — a terminal needs one
17/// output, not one per pane — and, on Windows, unsound: opening a device sink,
18/// letting the opening thread exit, then opening another from a second thread
19/// faults inside WASAPI with `STATUS_ACCESS_VIOLATION`. Sharing a single device
20/// for the life of the process avoids the re-open entirely.
21///
22/// Returns `None` when no output device could be opened (headless CI, no sound
23/// card, audio feature disabled at build time); callers treat that as "no bell".
24pub fn shared() -> Option<&'static AudioBell> {
25 static SHARED: std::sync::OnceLock<Option<AudioBell>> = std::sync::OnceLock::new();
26 SHARED
27 .get_or_init(|| match AudioBell::new() {
28 Ok(bell) => {
29 log::info!("Audio bell initialized successfully");
30 Some(bell)
31 }
32 Err(e) => {
33 log::warn!("Failed to initialize audio bell: {}", e);
34 None
35 }
36 })
37 .as_ref()
38}
39
40/// Audio bell manager for playing terminal bell sounds.
41///
42/// When the `audio` feature is enabled this uses `rodio` for real audio
43/// playback. When disabled, all methods are no-ops so callers never need
44/// conditional code.
45pub struct AudioBell {
46 /// Audio output device sink handle (kept alive for the duration of the application)
47 #[cfg(feature = "audio")]
48 stream: Option<MixerDeviceSink>,
49 /// Audio player for playback
50 #[cfg(feature = "audio")]
51 sink: Option<Arc<Mutex<Player>>>,
52}
53
54#[cfg(feature = "audio")]
55impl Drop for AudioBell {
56 fn drop(&mut self) {
57 // Stop and clear the sink BEFORE forgetting the stream
58 // This prevents use-after-free when sink tries to access the forgotten stream's mixer
59 // Note: If Arc has other references, they'll clean up on their own (try_unwrap fails)
60 if let Some(sink_arc) = self.sink.take()
61 && let Ok(sink) = Arc::try_unwrap(sink_arc)
62 {
63 let sink = sink.into_inner();
64 sink.stop();
65 }
66
67 // Suppress 'Dropping OutputStream' message by forgetting the stream
68 // Safe now that sink is stopped/dropped
69 if let Some(stream) = self.stream.take() {
70 std::mem::forget(stream);
71 }
72 }
73}
74
75// ---- audio feature enabled -------------------------------------------------
76
77#[cfg(feature = "audio")]
78impl AudioBell {
79 /// Create a new audio bell manager
80 pub fn new() -> Result<Self, String> {
81 let stream = DeviceSinkBuilder::open_default_sink()
82 .map_err(|e| format!("Failed to open audio stream: {}", e))?;
83
84 let sink = Player::connect_new(stream.mixer());
85
86 Ok(Self {
87 stream: Some(stream),
88 sink: Some(Arc::new(Mutex::new(sink))),
89 })
90 }
91
92 /// Create a dummy/disabled audio bell (safe fallback)
93 pub fn disabled() -> Self {
94 Self {
95 stream: None,
96 sink: None,
97 }
98 }
99
100 /// Play a bell sound with the specified volume (0-100)
101 ///
102 /// # Arguments
103 /// * `volume` - Volume level from 0 to 100. A value of 0 disables the bell sound.
104 pub fn play(&self, volume: u8) {
105 self.play_tone(volume, 800.0, 100);
106 }
107
108 /// Play a tone with configurable frequency and duration
109 ///
110 /// # Arguments
111 /// * `volume` - Volume level from 0 to 100. A value of 0 disables the sound.
112 /// * `frequency` - Frequency in Hz (e.g. 800.0 for standard bell)
113 /// * `duration_ms` - Duration in milliseconds
114 pub fn play_tone(&self, volume: u8, frequency: f32, duration_ms: u64) {
115 if volume == 0 {
116 return;
117 }
118
119 let sink_arc = match &self.sink {
120 Some(s) => s,
121 None => return, // Audio disabled
122 };
123
124 // Clamp volume to 0-100 range and convert to 0.0-1.0
125 let volume_f32 = (volume.min(100) as f32) / 100.0;
126
127 let source = rodio::source::SineWave::new(frequency)
128 .take_duration(Duration::from_millis(duration_ms))
129 .amplify(volume_f32 * 0.3); // Scale down to avoid being too loud
130
131 let sink = sink_arc.lock();
132 sink.append(source);
133 }
134
135 /// Play a sound file (WAV/MP3/OGG/FLAC) at the specified volume
136 ///
137 /// # Arguments
138 /// * `volume` - Volume level from 0 to 100
139 /// * `path` - Path to the sound file
140 pub fn play_file(&self, volume: u8, path: &std::path::Path) {
141 if volume == 0 {
142 return;
143 }
144
145 let sink_arc = match &self.sink {
146 Some(s) => s,
147 None => return,
148 };
149
150 let file = match std::fs::File::open(path) {
151 Ok(f) => f,
152 Err(e) => {
153 log::warn!("Failed to open alert sound file {:?}: {}", path, e);
154 return;
155 }
156 };
157
158 let reader = std::io::BufReader::new(file);
159 let source = match rodio::Decoder::new(reader) {
160 Ok(s) => s,
161 Err(e) => {
162 log::warn!("Failed to decode alert sound file {:?}: {}", path, e);
163 return;
164 }
165 };
166
167 let volume_f32 = (volume.min(100) as f32) / 100.0;
168 let source = source.amplify(volume_f32 * 0.5);
169
170 let sink = sink_arc.lock();
171 sink.append(source);
172 }
173
174 /// Play an alert sound using the given configuration
175 pub fn play_alert(&self, config: &crate::config::AlertSoundConfig) {
176 if !config.enabled || config.volume == 0 {
177 return;
178 }
179
180 if let Some(ref sound_file) = config.sound_file {
181 let path = std::path::Path::new(sound_file);
182 // Expand ~ to home directory
183 let expanded = if sound_file.starts_with('~') {
184 if let Some(home) = dirs::home_dir() {
185 home.join(&sound_file[2..])
186 } else {
187 path.to_path_buf()
188 }
189 } else {
190 path.to_path_buf()
191 };
192 self.play_file(config.volume, &expanded);
193 } else {
194 self.play_tone(config.volume, config.frequency, config.duration_ms);
195 }
196 }
197}
198
199#[cfg(feature = "audio")]
200impl Default for AudioBell {
201 /// A silent bell that owns no output device.
202 ///
203 /// This deliberately does **not** open a device. [`shared`] is the only
204 /// sanctioned way to obtain a working bell, because opening a second device
205 /// from another thread faults inside WASAPI on Windows. A `Default` that
206 /// quietly opened its own device re-introduced exactly that crash, and had
207 /// no callers outside tests. Matches the no-audio build's behaviour.
208 fn default() -> Self {
209 Self::disabled()
210 }
211}
212
213// ---- audio feature disabled (no-op stub) -----------------------------------
214
215#[cfg(not(feature = "audio"))]
216impl AudioBell {
217 /// Audio is disabled at compile time; always returns the no-op stub.
218 pub fn new() -> Result<Self, String> {
219 Ok(Self::disabled())
220 }
221
222 /// Create a no-op audio bell (no audio hardware is used).
223 pub fn disabled() -> Self {
224 Self {}
225 }
226
227 /// No-op — audio feature is disabled.
228 pub fn play(&self, _volume: u8) {}
229
230 /// No-op — audio feature is disabled.
231 pub fn play_tone(&self, _volume: u8, _frequency: f32, _duration_ms: u64) {}
232
233 /// No-op — audio feature is disabled.
234 pub fn play_file(&self, _volume: u8, _path: &std::path::Path) {}
235
236 /// No-op — audio feature is disabled.
237 pub fn play_alert(&self, _config: &crate::config::AlertSoundConfig) {}
238}
239
240#[cfg(not(feature = "audio"))]
241impl Default for AudioBell {
242 fn default() -> Self {
243 Self::disabled()
244 }
245}
246
247#[cfg(all(test, feature = "audio"))]
248mod tests_audio {
249 use super::*;
250
251 // These go through `shared()` rather than `AudioBell::new()` on purpose.
252 // libtest runs each test on its own thread, so constructing a device per
253 // test opens one, lets the thread exit, then opens another from a different
254 // thread — which faults inside WASAPI on Windows. `shared()` is also how
255 // production code obtains the bell, so this is the path worth covering.
256
257 #[test]
258 fn test_audio_bell_creation() {
259 // Opening the shared device must not panic, whether or not a device exists.
260 let _bell = shared();
261 }
262
263 #[test]
264 fn test_audio_bell_default() {
265 // Should not panic even if audio setup fails
266 let _bell = AudioBell::default();
267 }
268
269 #[test]
270 fn test_audio_bell_play_zero_volume() {
271 if let Some(bell) = shared() {
272 // Should not panic with zero volume
273 bell.play(0);
274 }
275 }
276
277 #[test]
278 fn test_audio_bell_play_max_volume() {
279 if let Some(bell) = shared() {
280 // Should not panic with max volume
281 bell.play(100);
282 }
283 }
284
285 #[test]
286 fn test_audio_bell_play_over_max_volume() {
287 if let Some(bell) = shared() {
288 // Should clamp to max volume without panicking
289 bell.play(150);
290 }
291 }
292
293 #[test]
294 fn test_disabled_bell() {
295 let bell = AudioBell::disabled();
296 // Should simply do nothing, not panic
297 bell.play(50);
298 }
299}
300
301#[cfg(all(test, not(feature = "audio")))]
302mod tests_no_audio {
303 use super::*;
304
305 #[test]
306 fn test_noop_bell_new() {
307 let bell = AudioBell::new();
308 assert!(bell.is_ok());
309 }
310
311 #[test]
312 fn test_noop_bell_default() {
313 let _bell = AudioBell::default();
314 }
315
316 #[test]
317 fn test_noop_bell_play() {
318 let bell = AudioBell::disabled();
319 bell.play(50);
320 bell.play_tone(50, 800.0, 100);
321 bell.play_file(50, std::path::Path::new("/nonexistent"));
322 bell.play_alert(&crate::config::AlertSoundConfig::default());
323 }
324}