Skip to main content

par_term/
audio_bell.rs

1use parking_lot::Mutex;
2use rodio::{OutputStream, OutputStreamBuilder, Sink, Source};
3use std::sync::Arc;
4use std::time::Duration;
5
6/// Audio bell manager for playing terminal bell sounds
7pub struct AudioBell {
8    /// Audio output stream handle (kept alive for the duration of the application)
9    stream: Option<OutputStream>,
10    /// Audio sink for playback
11    sink: Option<Arc<Mutex<Sink>>>,
12}
13
14impl Drop for AudioBell {
15    fn drop(&mut self) {
16        // Stop and clear the sink BEFORE forgetting the stream
17        // This prevents use-after-free when sink tries to access the forgotten stream's mixer
18        // Note: If Arc has other references, they'll clean up on their own (try_unwrap fails)
19        if let Some(sink_arc) = self.sink.take()
20            && let Ok(sink) = Arc::try_unwrap(sink_arc)
21        {
22            let sink = sink.into_inner();
23            sink.stop();
24        }
25
26        // Suppress 'Dropping OutputStream' message by forgetting the stream
27        // Safe now that sink is stopped/dropped
28        if let Some(stream) = self.stream.take() {
29            std::mem::forget(stream);
30        }
31    }
32}
33
34impl AudioBell {
35    /// Create a new audio bell manager
36    pub fn new() -> Result<Self, String> {
37        let stream = OutputStreamBuilder::open_default_stream()
38            .map_err(|e| format!("Failed to open audio stream: {}", e))?;
39
40        let sink = Sink::connect_new(stream.mixer());
41
42        Ok(Self {
43            stream: Some(stream),
44            sink: Some(Arc::new(Mutex::new(sink))),
45        })
46    }
47
48    /// Create a dummy/disabled audio bell (safe fallback)
49    pub fn disabled() -> Self {
50        Self {
51            stream: None,
52            sink: None,
53        }
54    }
55
56    /// Play a bell sound with the specified volume (0-100)
57    ///
58    /// # Arguments
59    /// * `volume` - Volume level from 0 to 100. A value of 0 disables the bell sound.
60    pub fn play(&self, volume: u8) {
61        self.play_tone(volume, 800.0, 100);
62    }
63
64    /// Play a tone with configurable frequency and duration
65    ///
66    /// # Arguments
67    /// * `volume` - Volume level from 0 to 100. A value of 0 disables the sound.
68    /// * `frequency` - Frequency in Hz (e.g. 800.0 for standard bell)
69    /// * `duration_ms` - Duration in milliseconds
70    pub fn play_tone(&self, volume: u8, frequency: f32, duration_ms: u64) {
71        if volume == 0 {
72            return;
73        }
74
75        let sink_arc = match &self.sink {
76            Some(s) => s,
77            None => return, // Audio disabled
78        };
79
80        // Clamp volume to 0-100 range and convert to 0.0-1.0
81        let volume_f32 = (volume.min(100) as f32) / 100.0;
82
83        let source = rodio::source::SineWave::new(frequency)
84            .take_duration(Duration::from_millis(duration_ms))
85            .amplify(volume_f32 * 0.3); // Scale down to avoid being too loud
86
87        let sink = sink_arc.lock();
88        sink.append(source);
89    }
90
91    /// Play a sound file (WAV/OGG/FLAC) at the specified volume
92    ///
93    /// # Arguments
94    /// * `volume` - Volume level from 0 to 100
95    /// * `path` - Path to the sound file
96    pub fn play_file(&self, volume: u8, path: &std::path::Path) {
97        if volume == 0 {
98            return;
99        }
100
101        let sink_arc = match &self.sink {
102            Some(s) => s,
103            None => return,
104        };
105
106        let file = match std::fs::File::open(path) {
107            Ok(f) => f,
108            Err(e) => {
109                log::warn!("Failed to open alert sound file {:?}: {}", path, e);
110                return;
111            }
112        };
113
114        let reader = std::io::BufReader::new(file);
115        let source = match rodio::Decoder::new(reader) {
116            Ok(s) => s,
117            Err(e) => {
118                log::warn!("Failed to decode alert sound file {:?}: {}", path, e);
119                return;
120            }
121        };
122
123        let volume_f32 = (volume.min(100) as f32) / 100.0;
124        let source = source.amplify(volume_f32 * 0.5);
125
126        let sink = sink_arc.lock();
127        sink.append(source);
128    }
129
130    /// Play an alert sound using the given configuration
131    pub fn play_alert(&self, config: &crate::config::AlertSoundConfig) {
132        if !config.enabled || config.volume == 0 {
133            return;
134        }
135
136        if let Some(ref sound_file) = config.sound_file {
137            let path = std::path::Path::new(sound_file);
138            // Expand ~ to home directory
139            let expanded = if sound_file.starts_with('~') {
140                if let Some(home) = dirs::home_dir() {
141                    home.join(&sound_file[2..])
142                } else {
143                    path.to_path_buf()
144                }
145            } else {
146                path.to_path_buf()
147            };
148            self.play_file(config.volume, &expanded);
149        } else {
150            self.play_tone(config.volume, config.frequency, config.duration_ms);
151        }
152    }
153}
154
155impl Default for AudioBell {
156    fn default() -> Self {
157        Self::new().unwrap_or_else(|e| {
158            log::warn!("Failed to initialize audio bell: {}", e);
159            Self::disabled()
160        })
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_audio_bell_creation() {
170        // Just ensure we can create the audio bell without panicking
171        let bell = AudioBell::new();
172        assert!(bell.is_ok() || bell.is_err());
173    }
174
175    #[test]
176    fn test_audio_bell_default() {
177        // Should not panic even if audio setup fails
178        let _bell = AudioBell::default();
179    }
180
181    #[test]
182    fn test_audio_bell_play_zero_volume() {
183        if let Ok(bell) = AudioBell::new() {
184            // Should not panic with zero volume
185            bell.play(0);
186        }
187    }
188
189    #[test]
190    fn test_audio_bell_play_max_volume() {
191        if let Ok(bell) = AudioBell::new() {
192            // Should not panic with max volume
193            bell.play(100);
194        }
195    }
196
197    #[test]
198    fn test_audio_bell_play_over_max_volume() {
199        if let Ok(bell) = AudioBell::new() {
200            // Should clamp to max volume without panicking
201            bell.play(150);
202        }
203    }
204
205    #[test]
206    fn test_disabled_bell() {
207        let bell = AudioBell::disabled();
208        // Should simply do nothing, not panic
209        bell.play(50);
210    }
211}