use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source}; use std::fs::File; use std::io::BufReader; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::thread; use crate::state::TimerPhase; // OutputStream is !Send, so we keep it alive in a dedicated thread. // Only OutputStreamHandle and Sink (both Send) cross thread boundaries. #[derive(Debug, Clone, Copy, PartialEq)] pub enum AmbientSound { Rain, Cafe, WhiteNoise, } impl AmbientSound { pub fn filename(&self) -> &'static str { match self { AmbientSound::Rain => "rain.ogg", AmbientSound::Cafe => "cafe.ogg", AmbientSound::WhiteNoise => "white_noise.ogg", } } pub fn name(&self) -> &'static str { match self { AmbientSound::Rain => "rain", AmbientSound::Cafe => "cafe", AmbientSound::WhiteNoise => "white_noise", } } pub fn from_str(s: &str) -> Option { match s { "rain" => Some(AmbientSound::Rain), "cafe" => Some(AmbientSound::Cafe), "white_noise" => Some(AmbientSound::WhiteNoise), _ => None, } } } pub struct AudioEngine { stream_handle: OutputStreamHandle, sink: Option, current_sound: Option, volume: f32, ducked: bool, duck_volume: f32, audio_dir: PathBuf, } impl AudioEngine { fn new_with_handle(stream_handle: OutputStreamHandle, audio_dir: PathBuf) -> Self { Self { stream_handle, sink: None, current_sound: None, volume: 0.5, ducked: false, duck_volume: 0.2, audio_dir, } } pub fn play(&mut self, sound: AmbientSound) -> Result<(), String> { self.stop(); let filename = sound.filename(); let path = self.audio_dir.join(filename); let file = File::open(&path).map_err(|e| format!("Cannot open {filename}: {e}"))?; let source = Decoder::new(BufReader::new(file)) .map_err(|e| format!("Decode error: {e}"))?; let sink = Sink::try_new(&self.stream_handle) .map_err(|e| format!("Sink error: {e}"))?; sink.append(source.repeat_infinite()); sink.set_volume(if self.ducked { self.duck_volume } else { self.volume }); sink.play(); self.current_sound = Some(sound); self.sink = Some(sink); Ok(()) } pub fn stop(&mut self) { if let Some(sink) = self.sink.take() { sink.stop(); } self.current_sound = None; } pub fn set_volume(&mut self, volume: f32) { self.volume = volume.clamp(0.0, 1.0); if let Some(ref sink) = self.sink { if !self.ducked { sink.set_volume(self.volume); } } } pub fn duck(&mut self) { self.ducked = true; if let Some(ref sink) = self.sink { sink.set_volume(self.duck_volume); } } pub fn unduck(&mut self) { self.ducked = false; if let Some(ref sink) = self.sink { sink.set_volume(self.volume); } } pub fn is_playing(&self) -> bool { self.sink.is_some() } pub fn current_sound(&self) -> Option { self.current_sound } pub fn volume(&self) -> f32 { self.volume } } pub fn should_duck_for_phase(phase: TimerPhase) -> bool { !matches!(phase, TimerPhase::Work) } /// Managed state: Arc>>. /// None means audio init failed (graceful degradation). pub struct AudioState(pub Arc>>); /// Initialise the audio subsystem. /// /// Spawns a dedicated thread to keep `OutputStream` alive (it is `!Send`). /// Returns `AudioState` whose inner `Option` is `None` if no audio device exists. pub fn init_audio(audio_dir: PathBuf) -> AudioState { let state: Arc>> = Arc::new(Mutex::new(None)); let state_clone = Arc::clone(&state); thread::spawn(move || { match OutputStream::try_default() { Ok((_stream, handle)) => { let engine = AudioEngine::new_with_handle(handle, audio_dir); { let mut guard = state_clone.lock().unwrap(); *guard = Some(engine); } // Park forever — _stream must stay alive to keep audio device open. loop { thread::park(); } } Err(e) => { eprintln!("[audio] No audio output device: {e}"); // state remains None } } }); // Give the audio thread a moment to initialise before returning. // Commands will work fine regardless due to Option guard. thread::sleep(std::time::Duration::from_millis(100)); AudioState(state) } #[cfg(test)] mod tests { use super::should_duck_for_phase; use crate::state::TimerPhase; #[test] fn work_phase_does_not_duck() { assert!(!should_duck_for_phase(TimerPhase::Work)); } #[test] fn break_phases_duck() { assert!(should_duck_for_phase(TimerPhase::ShortBreak)); assert!(should_duck_for_phase(TimerPhase::LongBreak)); } }