1use parking_lot::Mutex;
2use rodio::{OutputStream, OutputStreamBuilder, Sink, Source};
3use std::sync::Arc;
4use std::time::Duration;
5
6pub struct AudioBell {
8 stream: Option<OutputStream>,
10 sink: Option<Arc<Mutex<Sink>>>,
12}
13
14impl Drop for AudioBell {
15 fn drop(&mut self) {
16 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 if let Some(stream) = self.stream.take() {
29 std::mem::forget(stream);
30 }
31 }
32}
33
34impl AudioBell {
35 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 pub fn disabled() -> Self {
50 Self {
51 stream: None,
52 sink: None,
53 }
54 }
55
56 pub fn play(&self, volume: u8) {
61 self.play_tone(volume, 800.0, 100);
62 }
63
64 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, };
79
80 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); let sink = sink_arc.lock();
88 sink.append(source);
89 }
90
91 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 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 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 let bell = AudioBell::new();
172 assert!(bell.is_ok() || bell.is_err());
173 }
174
175 #[test]
176 fn test_audio_bell_default() {
177 let _bell = AudioBell::default();
179 }
180
181 #[test]
182 fn test_audio_bell_play_zero_volume() {
183 if let Ok(bell) = AudioBell::new() {
184 bell.play(0);
186 }
187 }
188
189 #[test]
190 fn test_audio_bell_play_max_volume() {
191 if let Ok(bell) = AudioBell::new() {
192 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 bell.play(150);
202 }
203 }
204
205 #[test]
206 fn test_disabled_bell() {
207 let bell = AudioBell::disabled();
208 bell.play(50);
210 }
211}