From 3019f7ffda7d7c82cfd8b31ea7397b0ab528ec65 Mon Sep 17 00:00:00 2001 From: Solstice Date: Tue, 9 Jun 2026 00:52:52 -0700 Subject: feat: ambient sound engine and volume controls --- src-tauri/src/audio.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 76 ++++++++++++++++++++ src-tauri/src/timer.rs | 10 +++ 3 files changed, 274 insertions(+) create mode 100644 src-tauri/src/audio.rs (limited to 'src-tauri/src') 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 { + 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)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c9ebbd9..d1864eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ +mod audio; mod state; mod storage; mod timer; use std::sync::{Arc, Mutex}; use chrono::Utc; +use serde::Serialize; use tauri::{AppHandle, Manager, State}; use uuid::Uuid; +use audio::AudioState; use state::{AppDataWrapper, TimerPhase, TimerState, TimerStateWrapper}; use storage::{Settings, Task}; @@ -252,6 +255,66 @@ fn delete_task( Ok(()) } +// ── Audio commands ────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct AudioStatus { + available: bool, + playing: bool, + sound: Option, + volume: f32, +} + +#[tauri::command] +fn play_ambient(sound: String, audio: State<'_, AudioState>) -> Result<(), String> { + let mut guard = audio.0.lock().unwrap(); + match guard.as_mut() { + None => Err("Audio not available".to_string()), + Some(engine) => { + if sound == "none" { + engine.stop(); + Ok(()) + } else { + match audio::AmbientSound::from_str(&sound) { + Some(s) => engine.play(s), + None => Err(format!("Unknown sound: {sound}")), + } + } + } + } +} + +#[tauri::command] +fn stop_ambient(audio: State<'_, AudioState>) { + if let Some(ref mut engine) = *audio.0.lock().unwrap() { + engine.stop(); + } +} + +#[tauri::command] +fn set_ambient_volume(volume: f32, audio: State<'_, AudioState>) -> Result<(), String> { + match audio.0.lock().unwrap().as_mut() { + None => Err("Audio not available".to_string()), + Some(engine) => { + engine.set_volume(volume); + Ok(()) + } + } +} + +#[tauri::command] +fn get_audio_status(audio: State<'_, AudioState>) -> AudioStatus { + match audio.0.lock().unwrap().as_ref() { + None => AudioStatus { available: false, playing: false, sound: None, volume: 0.5 }, + Some(engine) => AudioStatus { + available: true, + playing: engine.is_playing(), + sound: engine.current_sound().map(|s| s.name().to_string()), + volume: engine.volume(), + }, + } +} + // ── App entry point ───────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -281,11 +344,20 @@ pub fn run() { data: Arc::clone(&data_arc), }); + // Initialise audio engine (graceful if no device) + let audio_dir = app.handle().path().resource_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")) + .join("audio"); + let audio_state = audio::init_audio(audio_dir); + let audio_arc = Arc::clone(&audio_state.0); + app.manage(audio_state); + // Spawn background timer thread timer::spawn_timer_thread( app.handle().clone(), Arc::clone(&timer_arc), Arc::clone(&data_arc), + audio_arc, data_dir, ); @@ -304,6 +376,10 @@ pub fn run() { add_task, update_task, delete_task, + play_ambient, + stop_ambient, + set_ambient_volume, + get_audio_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs index 44e5999..322795e 100644 --- a/src-tauri/src/timer.rs +++ b/src-tauri/src/timer.rs @@ -4,6 +4,7 @@ use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter}; +use crate::audio::{self, AudioEngine}; use crate::state::{TimerPhase, TimerState}; use crate::storage::{self, AppData}; @@ -41,6 +42,7 @@ pub fn spawn_timer_thread( app_handle: AppHandle, timer_arc: Arc>, data_arc: Arc>, + audio_arc: Arc>>, data_dir: std::path::PathBuf, ) { thread::spawn(move || { @@ -128,6 +130,14 @@ pub fn spawn_timer_thread( } // Emit phase-changed after state is updated + if let Some(engine) = audio_arc.lock().unwrap().as_mut() { + if audio::should_duck_for_phase(ts.phase) { + engine.duck(); + } else { + engine.unduck(); + } + } + let phase_changed = PhaseChangedPayload { phase: ts.phase, session_count: ts.session_count, -- cgit v1.3-2-g0d8e