diff options
Diffstat (limited to 'src-tauri/src/audio.rs')
| -rw-r--r-- | src-tauri/src/audio.rs | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs new file mode 100644 index 0000000..a439a0c --- /dev/null +++ b/src-tauri/src/audio.rs @@ -0,0 +1,188 @@ +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<Self> { + match s { + "rain" => Some(AmbientSound::Rain), + "cafe" => Some(AmbientSound::Cafe), + "white_noise" => Some(AmbientSound::WhiteNoise), + _ => None, + } + } +} + +pub struct AudioEngine { + stream_handle: OutputStreamHandle, + sink: Option<Sink>, + current_sound: Option<AmbientSound>, + 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<AmbientSound> { + 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<Mutex<Option<AudioEngine>>>. +/// None means audio init failed (graceful degradation). +pub struct AudioState(pub Arc<Mutex<Option<AudioEngine>>>); + +/// 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<Mutex<Option<AudioEngine>>> = 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<AudioEngine> 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)); + } +} |
