use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source}; use std::fs::File; use std::io::BufReader; use std::path::{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, } const EXPECTED_AUDIO_FILES: [&str; 3] = ["rain.ogg", "cafe.ogg", "white_noise.ogg"]; pub fn directory_has_any_audio_files(dir: &Path) -> bool { EXPECTED_AUDIO_FILES .iter() .any(|filename| dir.join(filename).is_file()) } pub fn choose_audio_dir(app_data_audio_dir: PathBuf, bundled_audio_dir: PathBuf) -> PathBuf { if directory_has_any_audio_files(&app_data_audio_dir) { app_data_audio_dir } else { bundled_audio_dir } } 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, app_data_audio_dir: PathBuf, bundled_audio_dir: PathBuf, } impl AudioEngine { fn new_with_handle( stream_handle: OutputStreamHandle, app_data_audio_dir: PathBuf, bundled_audio_dir: PathBuf, ) -> Self { Self { stream_handle, sink: None, current_sound: None, volume: 0.5, ducked: false, duck_volume: 0.2, app_data_audio_dir, bundled_audio_dir, } } fn resolve_audio_path(&self, filename: &str) -> PathBuf { let app_data_path = self.app_data_audio_dir.join(filename); if app_data_path.is_file() { app_data_path } else { let bundled_path = self.bundled_audio_dir.join(filename); if bundled_path.is_file() { bundled_path } else { choose_audio_dir( self.app_data_audio_dir.clone(), self.bundled_audio_dir.clone(), ) .join(filename) } } } pub fn play(&mut self, sound: AmbientSound) -> Result<(), String> { self.stop(); let filename = sound.filename(); let path = self.resolve_audio_path(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 has_any_audio_files(&self) -> bool { directory_has_any_audio_files(&self.app_data_audio_dir) || directory_has_any_audio_files(&self.bundled_audio_dir) } } 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(app_data_audio_dir: PathBuf, bundled_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, app_data_audio_dir, bundled_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 std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use super::{choose_audio_dir, directory_has_any_audio_files, should_duck_for_phase}; use crate::state::TimerPhase; fn temp_path(label: &str) -> std::path::PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!("solstice-audio-{label}-{unique}")) } fn make_dir(path: &Path) { fs::create_dir_all(path).unwrap(); } fn write_audio_file(dir: &Path, name: &str) { fs::write(dir.join(name), b"test-audio").unwrap(); } #[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)); } #[test] fn detects_expected_audio_files_in_directory() { let dir = temp_path("detect-files"); make_dir(&dir); write_audio_file(&dir, "white_noise.ogg"); assert!(directory_has_any_audio_files(&dir)); } #[test] fn prefers_app_data_audio_when_files_exist_there() { let app_dir = temp_path("app-data"); let bundled_dir = temp_path("bundled"); make_dir(&app_dir); make_dir(&bundled_dir); write_audio_file(&app_dir, "rain.ogg"); write_audio_file(&bundled_dir, "cafe.ogg"); let chosen = choose_audio_dir(app_dir.clone(), bundled_dir.clone()); assert_eq!(chosen, app_dir); } #[test] fn falls_back_to_bundled_audio_when_app_data_is_empty() { let app_dir = temp_path("app-data-empty"); let bundled_dir = temp_path("bundled-fallback"); make_dir(&app_dir); make_dir(&bundled_dir); write_audio_file(&bundled_dir, "rain.ogg"); let chosen = choose_audio_dir(app_dir, bundled_dir.clone()); assert_eq!(chosen, bundled_dir); } }