summaryrefslogtreecommitdiff
path: root/src-tauri/src/audio.rs
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 00:58:04 -0700
committerSolstice <solstice@local>2026-06-09 00:58:04 -0700
commitf43f549ffbe3074977116c9f35aa7064d6a4bd95 (patch)
treec285fbcf8a768d0de2631a93e379d61cea0313f2 /src-tauri/src/audio.rs
parenta7e2a94ddb19864d4a1f78a420fcf66a9e38f4e5 (diff)
refactor: switch ambient audio to user-supplied assets
Diffstat (limited to 'src-tauri/src/audio.rs')
-rw-r--r--src-tauri/src/audio.rs117
1 files changed, 109 insertions, 8 deletions
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs
index a439a0c..11c970e 100644
--- a/src-tauri/src/audio.rs
+++ b/src-tauri/src/audio.rs
@@ -1,7 +1,7 @@
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
use std::fs::File;
use std::io::BufReader;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -17,6 +17,22 @@ pub enum AmbientSound {
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 {
@@ -51,11 +67,16 @@ pub struct AudioEngine {
volume: f32,
ducked: bool,
duck_volume: f32,
- audio_dir: PathBuf,
+ app_data_audio_dir: PathBuf,
+ bundled_audio_dir: PathBuf,
}
impl AudioEngine {
- fn new_with_handle(stream_handle: OutputStreamHandle, audio_dir: PathBuf) -> Self {
+ fn new_with_handle(
+ stream_handle: OutputStreamHandle,
+ app_data_audio_dir: PathBuf,
+ bundled_audio_dir: PathBuf,
+ ) -> Self {
Self {
stream_handle,
sink: None,
@@ -63,14 +84,33 @@ impl AudioEngine {
volume: 0.5,
ducked: false,
duck_volume: 0.2,
- audio_dir,
+ 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.audio_dir.join(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}"))?;
@@ -125,6 +165,11 @@ impl AudioEngine {
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 {
@@ -139,14 +184,14 @@ pub struct AudioState(pub Arc<Mutex<Option<AudioEngine>>>);
///
/// 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 {
+pub fn init_audio(app_data_audio_dir: PathBuf, bundled_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 engine = AudioEngine::new_with_handle(handle, app_data_audio_dir, bundled_audio_dir);
{
let mut guard = state_clone.lock().unwrap();
*guard = Some(engine);
@@ -172,9 +217,29 @@ pub fn init_audio(audio_dir: PathBuf) -> AudioState {
#[cfg(test)]
mod tests {
- use super::should_duck_for_phase;
+ 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));
@@ -185,4 +250,40 @@ mod tests {
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);
+ }
}