diff options
| author | Solstice <solstice@local> | 2026-06-09 00:52:52 -0700 |
|---|---|---|
| committer | Solstice <solstice@local> | 2026-06-09 00:52:52 -0700 |
| commit | 3019f7ffda7d7c82cfd8b31ea7397b0ab528ec65 (patch) | |
| tree | d10073c6223faf003212da50aa4c4c7b7e1d3082 | |
| parent | c973d48c41169240e3f53769804696fd0d352a09 (diff) | |
feat: ambient sound engine and volume controls
| -rw-r--r-- | src-tauri/Cargo.lock | 418 | ||||
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/audio/cafe.ogg | bin | 0 -> 254428 bytes | |||
| -rw-r--r-- | src-tauri/audio/rain.ogg | bin | 0 -> 256573 bytes | |||
| -rw-r--r-- | src-tauri/audio/white_noise.ogg | bin | 0 -> 263937 bytes | |||
| -rw-r--r-- | src-tauri/src/audio.rs | 188 | ||||
| -rw-r--r-- | src-tauri/src/lib.rs | 76 | ||||
| -rw-r--r-- | src-tauri/src/timer.rs | 10 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 3 | ||||
| -rw-r--r-- | src/App.tsx | 11 | ||||
| -rw-r--r-- | src/components/AmbientControl.tsx | 98 | ||||
| -rw-r--r-- | src/store/audioStore.ts | 84 |
12 files changed, 870 insertions, 19 deletions
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9d35925..cb2fbcd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -33,6 +33,28 @@ dependencies = [ ] [[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -48,6 +70,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -226,6 +254,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.117", +] + +[[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -417,7 +463,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -427,6 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] name = "cfb" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -468,6 +525,23 @@ dependencies = [ ] [[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "claxon" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" + +[[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -537,6 +611,49 @@ dependencies = [ ] [[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -653,6 +770,12 @@ dependencies = [ ] [[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] name = "dbus" version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -838,6 +961,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] name = "embed-resource" version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -858,6 +987,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1450,6 +1588,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] name = "html5ever" version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1748,6 +1892,15 @@ dependencies = [ ] [[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1821,6 +1974,16 @@ dependencies = [ ] [[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] name = "js-sys" version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1865,12 +2028,29 @@ dependencies = [ ] [[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] name = "libappindicator" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1890,7 +2070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -1920,6 +2100,16 @@ dependencies = [ ] [[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] name = "libredox" version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1968,6 +2158,15 @@ dependencies = [ ] [[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] name = "markup5ever" version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2000,6 +2199,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2043,6 +2248,20 @@ dependencies = [ [[package]] name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" @@ -2050,13 +2269,28 @@ dependencies = [ "bitflags 2.13.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle", "thiserror 1.0.69", ] [[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] name = "ndk-sys" version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2072,6 +2306,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] name = "notify-rust" version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2092,6 +2336,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2319,6 +2574,38 @@ dependencies = [ ] [[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2827,6 +3114,20 @@ dependencies = [ ] [[package]] +name = "rodio" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +dependencies = [ + "claxon", + "cpal", + "hound", + "lewton", + "symphonia", + "thiserror 1.0.69", +] + +[[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3126,6 +3427,12 @@ dependencies = [ [[package]] name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" @@ -3182,7 +3489,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk", + "ndk 0.9.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3270,6 +3577,55 @@ dependencies = [ ] [[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3344,8 +3700,8 @@ dependencies = [ "jni", "libc", "log", - "ndk", - "ndk-sys", + "ndk 0.9.0", + "ndk-sys 0.6.0+11769913", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3357,7 +3713,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3428,7 +3784,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3436,6 +3792,7 @@ name = "tauri-app" version = "0.1.0" dependencies = [ "chrono", + "rodio", "serde", "serde_json", "tauri", @@ -3560,7 +3917,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -3586,7 +3943,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3611,7 +3968,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -3672,7 +4029,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-version", ] @@ -4428,7 +4785,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4452,7 +4809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4504,6 +4861,16 @@ dependencies = [ [[package]] name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" @@ -4526,6 +4893,16 @@ dependencies = [ [[package]] name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" @@ -4607,6 +4984,15 @@ dependencies = [ [[package]] name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" @@ -4961,7 +5347,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -4979,7 +5365,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 214a48f..adbd36e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,4 +25,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } +rodio = "0.19" diff --git a/src-tauri/audio/cafe.ogg b/src-tauri/audio/cafe.ogg Binary files differnew file mode 100644 index 0000000..cbc1007 --- /dev/null +++ b/src-tauri/audio/cafe.ogg diff --git a/src-tauri/audio/rain.ogg b/src-tauri/audio/rain.ogg Binary files differnew file mode 100644 index 0000000..fd5d478 --- /dev/null +++ b/src-tauri/audio/rain.ogg diff --git a/src-tauri/audio/white_noise.ogg b/src-tauri/audio/white_noise.ogg Binary files differnew file mode 100644 index 0000000..fcbd7bc --- /dev/null +++ b/src-tauri/audio/white_noise.ogg 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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b39e99f..e634dca 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,6 +34,7 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": ["audio/*"] } } diff --git a/src/App.tsx b/src/App.tsx index 966e8e1..cf1e6a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,8 +3,10 @@ import { TimerView } from './components/TimerView'; import { TaskList } from './components/TaskList'; import { SettingsPanel } from './components/SettingsPanel'; import { NotificationOverlay } from './components/NotificationOverlay'; +import { AmbientControl } from './components/AmbientControl'; import { useTimerEvents } from './hooks/useTimerEvents'; import { useTaskStore } from './store/taskStore'; +import { useAudioStore } from './store/audioStore'; import { useSettingsStore } from './store/settingsStore'; function GearIcon() { @@ -32,12 +34,14 @@ function App() { const fetchTasks = useTaskStore((s) => s.fetchTasks); const fetchSettings = useSettingsStore((s) => s.fetchSettings); + const fetchAudioStatus = useAudioStore((s) => s.fetchStatus); // Bootstrap data on mount useEffect(() => { fetchTasks(); fetchSettings(); - }, [fetchTasks, fetchSettings]); + fetchAudioStatus(); + }, [fetchTasks, fetchSettings, fetchAudioStatus]); const handleCompleted = useCallback((taskId: string | null) => { setNotifTaskId(taskId); @@ -67,11 +71,14 @@ function App() { style={{ display: 'flex', alignItems: 'center', - justifyContent: 'flex-end', + justifyContent: 'space-between', + gap: '16px', padding: '12px 16px', borderBottom: '1px solid var(--line-1)', }} > + <AmbientControl /> + <button onClick={() => setSettingsOpen(true)} title="Settings" diff --git a/src/components/AmbientControl.tsx b/src/components/AmbientControl.tsx new file mode 100644 index 0000000..5b5017a --- /dev/null +++ b/src/components/AmbientControl.tsx @@ -0,0 +1,98 @@ +import { ChangeEvent } from 'react'; +import { useAudioStore, type AmbientSound } from '../store/audioStore'; + +const SOUND_OPTIONS: Array<{ value: AmbientSound; label: string }> = [ + { value: 'none', label: 'Silent' }, + { value: 'rain', label: 'Rain' }, + { value: 'cafe', label: 'Cafe' }, + { value: 'white_noise', label: 'White Noise' }, +]; + +export function AmbientControl() { + const { available, sound, volume, setSound, setVolume } = useAudioStore(); + + const handleSoundChange = async (event: ChangeEvent<HTMLSelectElement>) => { + await setSound(event.target.value as AmbientSound); + }; + + const handleVolumeChange = async (event: ChangeEvent<HTMLInputElement>) => { + await setVolume(Number(event.target.value)); + }; + + return ( + <div + style={{ + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '8px 10px', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-3)', + background: 'rgba(17, 21, 28, 0.85)', + boxShadow: 'var(--shadow-1)', + }} + > + <div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '90px' }}> + <span className="eyebrow" style={{ letterSpacing: '0.14em' }}> + Ambient + </span> + <span + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '12px', + color: available ? 'var(--fg-3)' : 'var(--negative)', + }} + > + {available ? 'Looping background audio' : 'Audio unavailable'} + </span> + </div> + + <select + value={sound} + onChange={handleSoundChange} + disabled={!available} + style={{ + fontFamily: 'var(--font-sans)', + fontSize: '13px', + color: available ? 'var(--fg-1)' : 'var(--fg-4)', + background: 'var(--ink-3)', + border: '1px solid var(--line-2)', + borderRadius: 'var(--r-2)', + padding: '6px 10px', + outline: 'none', + minWidth: '132px', + }} + > + {SOUND_OPTIONS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + + <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> + <input + type="range" + min={0} + max={1} + step={0.05} + value={volume} + onChange={handleVolumeChange} + disabled={!available} + style={{ width: '112px', accentColor: 'var(--brass)' }} + /> + <span + style={{ + fontFamily: 'var(--font-mono)', + fontSize: '12px', + color: available ? 'var(--fg-2)' : 'var(--fg-4)', + width: '36px', + textAlign: 'right', + }} + > + {Math.round(volume * 100)}% + </span> + </div> + </div> + ); +} diff --git a/src/store/audioStore.ts b/src/store/audioStore.ts new file mode 100644 index 0000000..39cbf8b --- /dev/null +++ b/src/store/audioStore.ts @@ -0,0 +1,84 @@ +import { invoke } from '@tauri-apps/api/core'; +import { create } from 'zustand'; + +export type AmbientSound = 'none' | 'rain' | 'cafe' | 'white_noise'; + +interface AudioStatus { + available: boolean; + playing: boolean; + sound: AmbientSound | null; + volume: number; +} + +interface AudioStore { + available: boolean; + playing: boolean; + sound: AmbientSound; + volume: number; + fetchStatus: () => Promise<void>; + setSound: (sound: AmbientSound) => Promise<void>; + setVolume: (volume: number) => Promise<void>; +} + +function normalizeSound(sound: AudioStatus['sound'], playing: boolean): AmbientSound { + if (!playing || sound === null) { + return 'none'; + } + + return sound; +} + +export const useAudioStore = create<AudioStore>((set, get) => ({ + available: true, + playing: false, + sound: 'none', + volume: 0.5, + + fetchStatus: async () => { + try { + const status = await invoke<AudioStatus>('get_audio_status'); + set({ + available: status.available, + playing: status.playing, + sound: normalizeSound(status.sound, status.playing), + volume: status.volume, + }); + } catch (error) { + console.error('get_audio_status error:', error); + set({ available: false, playing: false, sound: 'none' }); + } + }, + + setSound: async (sound) => { + try { + if (sound === 'none') { + await invoke('stop_ambient'); + set({ playing: false, sound: 'none' }); + return; + } + + await invoke('play_ambient', { sound }); + set({ available: true, playing: true, sound }); + } catch (error) { + console.error('play_ambient error:', error); + set({ available: false, playing: false, sound: 'none' }); + } + }, + + setVolume: async (volume) => { + const nextVolume = Math.min(1, Math.max(0, volume)); + set({ volume: nextVolume }); + + try { + await invoke('set_ambient_volume', { volume: nextVolume }); + set({ available: true }); + } catch (error) { + console.error('set_ambient_volume error:', error); + set({ + available: false, + playing: false, + sound: get().sound, + }); + } + }, +})); |
