summaryrefslogtreecommitdiff
path: root/src-tauri/src
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 00:52:52 -0700
committerSolstice <solstice@local>2026-06-09 00:52:52 -0700
commit3019f7ffda7d7c82cfd8b31ea7397b0ab528ec65 (patch)
treed10073c6223faf003212da50aa4c4c7b7e1d3082 /src-tauri/src
parentc973d48c41169240e3f53769804696fd0d352a09 (diff)
feat: ambient sound engine and volume controls
Diffstat (limited to 'src-tauri/src')
-rw-r--r--src-tauri/src/audio.rs188
-rw-r--r--src-tauri/src/lib.rs76
-rw-r--r--src-tauri/src/timer.rs10
3 files changed, 274 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));
+ }
+}
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<String>,
+ 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<Mutex<TimerState>>,
data_arc: Arc<Mutex<AppData>>,
+ audio_arc: Arc<Mutex<Option<AudioEngine>>>,
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,