summaryrefslogtreecommitdiff
path: root/src-tauri/src/audio.rs
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/audio.rs
parentc973d48c41169240e3f53769804696fd0d352a09 (diff)
feat: ambient sound engine and volume controls
Diffstat (limited to 'src-tauri/src/audio.rs')
-rw-r--r--src-tauri/src/audio.rs188
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));
+ }
+}