Skip to main content

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}