diff --git a/Cargo.lock b/Cargo.lock index 27e1a34..7f7a2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.11.0", @@ -46,9 +46,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -110,6 +110,7 @@ version = "0.1.0" dependencies = [ "rodio", "serde", + "signalsmith-stretch", "symphonia", "thiserror 2.0.18", "tracing", @@ -136,18 +137,20 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", "itertools", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn 2.0.117", ] @@ -322,8 +325,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -392,12 +393,6 @@ dependencies = [ "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" @@ -466,45 +461,46 @@ 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.17" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" dependencies = [ - "bindgen", + "bitflags 2.11.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", - "core-foundation-sys", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", "mach2", - "ndk 0.8.0", + "ndk", "ndk-context", - "oboe", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", ] [[package]] @@ -634,12 +630,125 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dasp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_signal", + "dasp_slice", + "dasp_window", +] + +[[package]] +name = "dasp_envelope" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6" +dependencies = [ + "dasp_frame", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_interpolate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_peak" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_ring_buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1" + +[[package]] +name = "dasp_rms" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dasp_signal" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_window", +] + +[[package]] +name = "dasp_slice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_window" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076" +dependencies = [ + "dasp_sample", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1398,12 +1507,6 @@ version = "0.4.3" 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.29.1" @@ -1765,16 +1868,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[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.91" @@ -1842,17 +1935,6 @@ 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" @@ -1941,9 +2023,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -2059,20 +2141,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2082,7 +2150,7 @@ dependencies = [ "bitflags 2.11.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2094,15 +2162,6 @@ 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", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2134,6 +2193,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2151,6 +2220,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2205,6 +2294,54 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2212,7 +2349,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2252,6 +2391,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2305,38 +2445,6 @@ dependencies = [ "objc2-foundation", ] -[[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" @@ -2978,18 +3086,22 @@ dependencies = [ [[package]] name = "rodio" -version = "0.19.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ - "claxon", "cpal", - "hound", - "lewton", - "symphonia", - "thiserror 1.0.69", + "dasp_sample", + "num-rational", + "thiserror 2.0.18", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3144,7 +3256,7 @@ dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.1", "servo_arc 0.4.3", "smallvec", ] @@ -3343,6 +3455,17 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signalsmith-stretch" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae6f10b5532510f65c309c4d868babe3aecf6ce0782678081338311f176fd" +dependencies = [ + "bindgen", + "cc", + "dasp", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3391,7 +3514,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk 0.9.0", + "ndk", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3520,6 +3643,7 @@ dependencies = [ "symphonia-bundle-mp3", "symphonia-codec-aac", "symphonia-codec-pcm", + "symphonia-codec-vorbis", "symphonia-core", "symphonia-format-isomp4", "symphonia-format-ogg", @@ -3572,6 +3696,17 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-core" version = "0.5.5" @@ -3719,9 +3854,9 @@ dependencies = [ "jni", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3731,7 +3866,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3802,7 +3937,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows", ] [[package]] @@ -3921,7 +4056,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", ] [[package]] @@ -3946,7 +4081,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", "wry", ] @@ -4091,21 +4226,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.50.0" @@ -4764,7 +4884,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4788,7 +4908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows 0.61.3", + "windows", "windows-core 0.61.2", ] @@ -4838,16 +4958,6 @@ dependencies = [ "windows-version", ] -[[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" @@ -4870,16 +4980,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[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" @@ -4961,15 +5061,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[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" @@ -5397,7 +5488,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk 0.9.0", + "ndk", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -5415,7 +5506,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/README.md b/README.md index 6eee1ef..c8011e5 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ async function checkPlayer() { } ``` -#### Load, play, pause, stop, or seek +#### Prepare, play, pause, stop, or seek The API uses discriminated unions with type guards for compile-time safety. Only valid transport actions are available based on the player's status. @@ -158,11 +158,11 @@ import { getPlayer, PlaybackStatus, hasAction, AudioAction, } from '@silvermine/tauri-plugin-audio'; -async function loadAndPlay() { +async function prepareAndPlay() { const player = await getPlayer(); if (player.status === PlaybackStatus.Idle) { - const { player: ready } = await player.load( + const { player: ready } = await player.prepare( 'https://example.com/song.mp3', { title: 'My Song', @@ -260,13 +260,13 @@ the current `PlaybackStatus`: | Status | Allowed Actions | | --------- | ---------------------------- | -| Idle | load | +| Idle | prepare | | Loading | stop | | Ready | play, seek, stop | | Playing | pause, seek, stop | | Paused | play, seek, stop | -| Ended | play, seek, load, stop | -| Error | load | +| Ended | play, seek, prepare, stop | +| Error | prepare | Settings (`setVolume`, `setMuted`, `setPlaybackRate`, `setLoop`), `listen`, and `onTimeUpdate` are always available regardless of diff --git a/crates/audio-player/Cargo.toml b/crates/audio-player/Cargo.toml index 1665b5f..7ec7ede 100644 --- a/crates/audio-player/Cargo.toml +++ b/crates/audio-player/Cargo.toml @@ -7,9 +7,10 @@ edition = "2024" rust-version = "1.89" [dependencies] -rodio = "0.19.0" -symphonia = { version = "0.5.5", default-features = false, features = ["mp3", "wav", "flac", "ogg", "isomp4", "aac", "pcm"] } +rodio = { version = "0.22.2", default-features = false, features = ["playback"] } serde = { version = "1.0.228", features = ["derive"] } +signalsmith-stretch = "0.1.3" +symphonia = { version = "0.5.5", default-features = false, features = ["aac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] } thiserror = "2.0.17" tracing = { version = "0.1.41", default-features = false, features = ["std", "log"] } ureq = "2.12.1" diff --git a/crates/audio-player/src/models.rs b/crates/audio-player/src/models.rs index 3970d25..be49c65 100644 --- a/crates/audio-player/src/models.rs +++ b/crates/audio-player/src/models.rs @@ -96,7 +96,7 @@ pub struct TimeUpdate { pub duration: f64, } -/// Response from a transport action (load, play, pause, stop, seek). +/// Response from a transport action (prepare, play, pause, stop, seek). /// /// Wraps the resulting [`PlayerState`] with status-expectation metadata so the /// TypeScript layer can detect unexpected state transitions. diff --git a/crates/audio-player/src/player.rs b/crates/audio-player/src/player.rs deleted file mode 100644 index 7ebb46d..0000000 --- a/crates/audio-player/src/player.rs +++ /dev/null @@ -1,543 +0,0 @@ -use std::io::{Cursor, Read}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::Duration; - -use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source}; -use tracing::warn; - -use crate::error::{Error, Result}; -use crate::models::{AudioActionResponse, AudioMetadata, PlaybackStatus, PlayerState, TimeUpdate}; -use crate::net::reject_private_host; -use crate::{OnChanged, OnTimeUpdate, transitions}; - -/// Maximum audio download size (100 MiB). -const MAX_DOWNLOAD_BYTES: u64 = 100 * 1024 * 1024; - -/// HTTP request timeout (connect + read combined). -const HTTP_TIMEOUT: Duration = Duration::from_secs(30); - -/// Audio player backed by Rodio for cross-platform desktop playback. -/// -/// Manages a dedicated audio output thread, a playback monitor for time updates -/// and end-of-track detection, and a state machine matching the plugin's -/// [`PlaybackStatus`] model. -pub struct RodioAudioPlayer { - inner: Arc>, - stream_handle: OutputStreamHandle, - /// Dropping this sender signals the audio output thread to exit. - _stream_keep_alive: std::sync::mpsc::Sender<()>, - on_changed: OnChanged, - on_time_update: OnTimeUpdate, -} - -struct Inner { - state: PlayerState, - playback: Option, - monitor_stop: Arc, -} - -struct PlaybackContext { - sink: Sink, - /// Raw audio bytes kept for looping re-append and replay from Ended. - /// Wrapped in `Arc` so re-append clones are cheap reference count bumps - /// instead of multi-megabyte copies. - source_data: Arc<[u8]>, - duration: f64, -} - -impl RodioAudioPlayer { - /// Creates a new Rodio-backed audio player. - /// - /// Opens the default audio output device on a dedicated thread. Returns an error - /// if no audio device is available. - pub fn new(on_changed: OnChanged, on_time_update: OnTimeUpdate) -> Result { - let stream_handle = open_audio_output()?; - - Ok(Self { - inner: Arc::new(Mutex::new(Inner { - state: PlayerState::default(), - playback: None, - monitor_stop: Arc::new(AtomicBool::new(true)), - })), - stream_handle: stream_handle.handle, - _stream_keep_alive: stream_handle.keep_alive, - on_changed, - on_time_update, - }) - } - - /// Stops the monitor thread by setting the flag. - fn stop_monitor(inner: &Inner) { - inner.monitor_stop.store(true, Ordering::Relaxed); - } - - /// Spawns a new monitor thread for time updates and end-of-track detection. - /// - /// The old monitor thread may briefly overlap (up to 250ms) until it - /// observes the stop flag on its next poll. This is harmless — any - /// duplicate time updates are benign, and the state is already updated - /// under the mutex before the new monitor starts, so the old one cannot - /// trigger a spurious Ended transition. - fn start_monitor(&self, inner: &mut Inner) { - let stop = Arc::new(AtomicBool::new(false)); - inner.monitor_stop = stop.clone(); - - let inner_arc = Arc::clone(&self.inner); - let on_changed = Arc::clone(&self.on_changed); - let on_time_update = Arc::clone(&self.on_time_update); - - if let Err(e) = std::thread::Builder::new() - .name("audio-monitor".into()) - .spawn(move || { - monitor_loop(stop, inner_arc, on_changed, on_time_update); - }) - { - warn!("Failed to spawn audio monitor thread: {e}"); - } - } - - pub fn get_state(&self) -> PlayerState { - lock_inner(&self.inner).state.clone() - } - - pub fn load(&self, src: &str, metadata: Option) -> Result { - let meta = metadata.unwrap_or_default(); - - // Transition to Loading and notify the frontend before starting I/O. - { - let mut inner = lock_inner(&self.inner); - transitions::begin_load(&mut inner.state, src, &meta)?; - let snapshot = inner.state.clone(); - drop(inner); - (self.on_changed)(&snapshot); - } - - // Perform I/O, decoding, and sink creation. If any step fails, - // transition to Error so the frontend can recover from the Loading state. - let result = self.load_inner(src, &meta); - - match result { - Ok(snapshot) => { - (self.on_changed)(&snapshot); - Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Ready)) - } - Err(e) => { - let mut inner = lock_inner(&self.inner); - transitions::error(&mut inner.state, e.to_string()); - let snapshot = inner.state.clone(); - drop(inner); - (self.on_changed)(&snapshot); - Err(e) - } - } - } - - /// Inner load logic that may fail. Separated so `load()` can catch errors - /// and transition to the Error state before propagating. - fn load_inner(&self, src: &str, meta: &AudioMetadata) -> Result { - // Fetch audio data (may block on file I/O or HTTP download). - let data: Arc<[u8]> = load_source_data(src)?.into(); - - // Decode audio and extract duration. - let source = Decoder::new(Cursor::new(Arc::clone(&data))) - .map_err(|e| Error::Audio(format!("Failed to decode audio: {e}")))?; - let duration = source - .total_duration() - .map(|d| d.as_secs_f64()) - .unwrap_or_else(|| probe_duration(&data).unwrap_or(0.0)); - - // Create a new sink, append the decoded source, and pause immediately - // so playback waits for an explicit play() call. - let sink = Sink::try_new(&self.stream_handle) - .map_err(|e| Error::Audio(format!("Failed to create audio sink: {e}")))?; - sink.pause(); - sink.append(source); - - // Commit the state transition under the lock. - let mut inner = lock_inner(&self.inner); - - // Re-check after I/O — another thread may have changed the state. - transitions::load(&mut inner.state, src, meta, duration)?; - - Self::stop_monitor(&inner); - - // Apply current user settings to the new sink. - sink.set_volume(effective_volume(&inner.state)); - sink.set_speed(inner.state.playback_rate as f32); - - inner.playback = Some(PlaybackContext { - sink, - source_data: data, - duration, - }); - - Ok(inner.state.clone()) - } - - pub fn play(&self) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - let is_ended = inner.state.status == PlaybackStatus::Ended; - - // Re-append source for replay from Ended before the transition - // mutates status. - if is_ended - && let Some(ctx) = &inner.playback - && ctx.sink.empty() - && let Some(source) = decode_arc(&ctx.source_data) - { - ctx.sink.append(source); - } - - transitions::play(&mut inner.state)?; - - if is_ended { - inner.state.current_time = 0.0; - } - - if let Some(ctx) = &inner.playback { - ctx.sink.play(); - } - - self.start_monitor(&mut inner); - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Playing)) - } - - pub fn pause(&self) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - - transitions::pause(&mut inner.state)?; - - if let Some(ctx) = &inner.playback { - ctx.sink.pause(); - } - - Self::stop_monitor(&inner); - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Paused)) - } - - pub fn stop(&self) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - - transitions::stop(&mut inner.state)?; - - Self::stop_monitor(&inner); - - // Clear the sink's queue before dropping so Sink::drop returns - // immediately instead of blocking until the audio drains. - if let Some(ctx) = inner.playback.take() { - ctx.sink.stop(); - } - - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Idle)) - } - - pub fn seek(&self, position: f64) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - let was_ended = inner.state.status == PlaybackStatus::Ended; - - transitions::seek(&mut inner.state, position)?; - - if let Some(ctx) = &inner.playback { - // If ended, re-append the source so we have something to seek within. - if was_ended { - if let Some(source) = decode_arc(&ctx.source_data) { - ctx.sink.append(source); - } - ctx.sink.pause(); - } - - if let Err(e) = ctx - .sink - .try_seek(Duration::from_secs_f64(inner.state.current_time)) - { - warn!("Seek failed: {e}"); - } - } - - inner.state.clone() - }; - - let expected = snapshot.status; - (self.on_changed)(&snapshot); - Ok(AudioActionResponse::new(snapshot, expected)) - } - - pub fn set_volume(&self, level: f64) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - transitions::set_volume(&mut inner.state, level)?; - if let Some(ctx) = &inner.playback { - ctx.sink.set_volume(effective_volume(&inner.state)); - } - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - Ok(snapshot) - } - - pub fn set_muted(&self, muted: bool) -> PlayerState { - let snapshot = { - let mut inner = lock_inner(&self.inner); - transitions::set_muted(&mut inner.state, muted); - if let Some(ctx) = &inner.playback { - ctx.sink.set_volume(effective_volume(&inner.state)); - } - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - snapshot - } - - pub fn set_playback_rate(&self, rate: f64) -> Result { - let snapshot = { - let mut inner = lock_inner(&self.inner); - transitions::set_playback_rate(&mut inner.state, rate)?; - if let Some(ctx) = &inner.playback { - ctx.sink.set_speed(inner.state.playback_rate as f32); - } - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - Ok(snapshot) - } - - pub fn set_loop(&self, looping: bool) -> PlayerState { - let snapshot = { - let mut inner = lock_inner(&self.inner); - transitions::set_loop(&mut inner.state, looping); - inner.state.clone() - }; - - (self.on_changed)(&snapshot); - snapshot - } -} - -// --------------------------------------------------------------------------- -// Audio output thread -// --------------------------------------------------------------------------- - -struct AudioOutput { - handle: OutputStreamHandle, - keep_alive: std::sync::mpsc::Sender<()>, -} - -/// Opens the default audio output device on a dedicated thread. -/// -/// The [`OutputStream`] must remain on the thread that created it (platform -/// requirement on some backends). We keep it alive via a channel — dropping the -/// returned sender signals the thread to exit. -fn open_audio_output() -> Result { - let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1); - let (keep_alive_tx, keep_alive_rx) = std::sync::mpsc::channel::<()>(); - - std::thread::Builder::new() - .name("audio-output".into()) - .spawn(move || match OutputStream::try_default() { - Ok((_stream, handle)) => { - let _ = result_tx.send(Ok(handle)); - // Block until the keep_alive sender is dropped. - let _ = keep_alive_rx.recv(); - } - Err(e) => { - let _ = result_tx.send(Err(e)); - } - }) - .map_err(|e| Error::Audio(format!("Failed to spawn audio thread: {e}")))?; - - let handle = result_rx - .recv() - .map_err(|_| Error::Audio("Audio thread terminated unexpectedly".into()))? - .map_err(|e| Error::Audio(format!("Failed to open audio device: {e}")))?; - - Ok(AudioOutput { - handle, - keep_alive: keep_alive_tx, - }) -} - -// --------------------------------------------------------------------------- -// Playback monitor -// --------------------------------------------------------------------------- - -/// Polls the sink every 250ms for position updates and end-of-track detection. -fn monitor_loop( - stop: Arc, - inner: Arc>, - on_changed: OnChanged, - on_time_update: OnTimeUpdate, -) { - loop { - std::thread::sleep(Duration::from_millis(250)); - - if stop.load(Ordering::Relaxed) { - break; - } - - let mut guard = lock_inner(&inner); - - let (pos, duration, is_empty) = match &guard.playback { - Some(ctx) => ( - ctx.sink.get_pos().as_secs_f64(), - ctx.duration, - ctx.sink.empty(), - ), - None => break, - }; - - if is_empty { - if guard.state.looping { - // Re-append source for seamless (best-effort) loop. - if let Some(ctx) = &guard.playback - && let Some(source) = decode_arc(&ctx.source_data) - { - ctx.sink.append(source); - } - guard.state.current_time = 0.0; - drop(guard); - on_time_update(&TimeUpdate { - current_time: 0.0, - duration, - }); - } else { - guard.state.status = PlaybackStatus::Ended; - guard.state.current_time = duration; - let snapshot = guard.state.clone(); - drop(guard); - on_changed(&snapshot); - break; - } - } else { - guard.state.current_time = pos; - drop(guard); - on_time_update(&TimeUpdate { - current_time: pos, - duration, - }); - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Acquires the mutex, recovering from poisoning instead of panicking. -/// -/// A poisoned mutex means a thread panicked while holding the lock. The inner -/// data may be in an inconsistent state, but for an audio player the worst case -/// is a glitched playback state — far better than crashing the host application. -fn lock_inner(mutex: &Mutex) -> MutexGuard<'_, Inner> { - mutex.lock().unwrap_or_else(|e| e.into_inner()) -} - -/// Creates a new decoder from shared audio data (cheap Arc clone, no byte copy). -fn decode_arc(data: &Arc<[u8]>) -> Option>>> { - Decoder::new(Cursor::new(Arc::clone(data))).ok() -} - -/// Resolves the effective sink volume, accounting for the mute flag. -fn effective_volume(state: &PlayerState) -> f32 { - if state.muted { - 0.0 - } else { - state.volume as f32 - } -} - -/// Probes audio data with symphonia to determine duration from container metadata. -/// -/// This succeeds for most common formats (MP3, FLAC, WAV, OGG, AAC) where -/// `rodio::Decoder::total_duration()` returns `None`. -fn probe_duration(data: &Arc<[u8]>) -> Option { - use symphonia::core::formats::FormatOptions; - use symphonia::core::io::MediaSourceStream; - use symphonia::core::meta::MetadataOptions; - use symphonia::core::probe::Hint; - - let cursor = Cursor::new(Arc::clone(data)); - let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); - - let probed = symphonia::default::get_probe() - .format( - &Hint::new(), - mss, - &FormatOptions::default(), - &MetadataOptions::default(), - ) - .ok()?; - - let track = probed.format.default_track()?; - let time_base = track.codec_params.time_base?; - let n_frames = track.codec_params.n_frames?; - let time = time_base.calc_time(n_frames); - - Some(time.seconds as f64 + time.frac) -} - -/// Loads raw audio bytes from a file path or HTTP(S) URL. -fn load_source_data(src: &str) -> Result> { - if src.starts_with("http://") || src.starts_with("https://") { - reject_private_host(src)?; - - let resp = ureq::AgentBuilder::new() - .timeout(HTTP_TIMEOUT) - .redirects(0) - .build() - .get(src) - .call() - .map_err(|e| Error::Http(format!("Failed to fetch {src}: {e}")))?; - - // Reject early if Content-Length exceeds the limit. - if let Some(len) = resp - .header("content-length") - .and_then(|v| v.parse::().ok()) - && len > MAX_DOWNLOAD_BYTES - { - return Err(Error::Http(format!( - "Response too large ({len} bytes, max {MAX_DOWNLOAD_BYTES})" - ))); - } - - // Enforce the limit regardless of Content-Length (it can be absent or spoofed). - let mut bytes = Vec::new(); - resp - .into_reader() - .take(MAX_DOWNLOAD_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(Error::Io)?; - - if bytes.len() as u64 > MAX_DOWNLOAD_BYTES { - return Err(Error::Http(format!( - "Response exceeded maximum size of {MAX_DOWNLOAD_BYTES} bytes" - ))); - } - - Ok(bytes) - } else { - if src.contains("://") || src.starts_with("data:") { - return Err(Error::Http(format!("Unsupported URL scheme: {src}"))); - } - std::fs::read(src).map_err(Error::Io) - } -} diff --git a/crates/audio-player/src/player/http.rs b/crates/audio-player/src/player/http.rs new file mode 100644 index 0000000..5f07b35 --- /dev/null +++ b/crates/audio-player/src/player/http.rs @@ -0,0 +1,332 @@ +use std::io::{Read, Seek, SeekFrom}; +use std::time::Duration; + +use symphonia::core::io::MediaSource; + +use super::source::infer_hint; + +use crate::error::{Error, Result}; + +/// HTTP request timeout (connect + read combined). +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone)] +pub(crate) struct RemoteSourceDescriptor { + pub(crate) url: String, + pub(crate) byte_len: Option, + pub(crate) mime_type: Option, + pub(crate) hint: Option, +} + +pub(crate) struct HttpAudioReader { + url: String, + position: u64, + byte_len: Option, + reader: Option, + reached_eof: bool, +} + +struct HttpResponseReader { + inner: Box, +} + +pub(crate) fn fetch_remote_source_descriptor(src: &str) -> Result { + let resp = match descriptor_probe_request(src, true) { + Ok(resp) => resp, + Err(error) if matches!(error.as_ref(), ureq::Error::Status(_, _)) => { + descriptor_probe_request(src, false) + .map_err(|e| Error::Http(format!("Failed to fetch {src}: {e}")))? + } + Err(error) => return Err(Error::Http(format!("Failed to fetch {src}: {error}"))), + }; + + Ok(RemoteSourceDescriptor { + url: src.to_string(), + byte_len: parse_byte_len(&resp), + mime_type: resp.header("content-type").map(str::to_string), + hint: infer_hint(src), + }) +} + +fn descriptor_probe_request( + src: &str, + use_range: bool, +) -> std::result::Result> { + let request = descriptor_http_agent() + .get(src) + .set("Accept-Encoding", "identity"); + let request = if use_range { + request.set("Range", "bytes=0-0") + } else { + request + }; + + request.call().map_err(Box::new) +} + +fn descriptor_http_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout(HTTP_TIMEOUT) + .redirects(0) + .build() +} + +fn stream_http_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(HTTP_TIMEOUT) + .timeout_read(HTTP_TIMEOUT) + .redirects(0) + .build() +} + +fn parse_byte_len(resp: &ureq::Response) -> Option { + resp + .header("content-range") + .and_then(parse_content_range_len) + .or_else(|| { + resp + .header("content-length") + .and_then(|value| value.parse::().ok()) + }) +} + +fn parse_content_range_len(value: &str) -> Option { + value.rsplit('/').next()?.parse::().ok() +} + +fn open_http_stream(url: &str, position: u64) -> Result<(HttpResponseReader, Option)> { + let request = stream_http_agent() + .get(url) + .set("Accept-Encoding", "identity"); + let request = if position > 0 { + request.set("Range", &format!("bytes={position}-")) + } else { + request + }; + + let resp = request + .call() + .map_err(|e| Error::Http(format!("Failed to fetch {url}: {e}")))?; + let status = resp.status(); + let byte_len = parse_byte_len(&resp); + let mut reader = HttpResponseReader::new(resp); + + if position > 0 && status != 206 { + skip_bytes(&mut reader, position).map_err(Error::Io)?; + } + + Ok((reader, byte_len)) +} + +fn skip_bytes(reader: &mut R, mut remaining: u64) -> std::io::Result<()> { + let mut buffer = [0_u8; 8192]; + + while remaining > 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap_or(buffer.len()); + let read = reader.read(&mut buffer[..chunk_len])?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Unexpected EOF while skipping remote stream", + )); + } + remaining -= read as u64; + } + + Ok(()) +} + +impl HttpResponseReader { + fn new(response: ureq::Response) -> Self { + Self { + inner: response.into_reader(), + } + } +} + +impl Read for HttpResponseReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.read(buf) + } +} + +impl HttpAudioReader { + pub(crate) fn new(url: String, byte_len: Option) -> Self { + Self { + url, + position: 0, + byte_len, + reader: None, + reached_eof: false, + } + } + + fn ensure_reader(&mut self) -> std::io::Result<()> { + if self.reader.is_none() && !self.reached_eof { + let (reader, byte_len) = open_http_stream(&self.url, self.position) + .map_err(|error| std::io::Error::other(error.to_string()))?; + + if self.byte_len.is_none() { + self.byte_len = byte_len; + } + + self.reader = Some(reader); + } + + Ok(()) + } +} + +impl Read for HttpAudioReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.ensure_reader()?; + + let Some(reader) = &mut self.reader else { + self.reached_eof = true; + return Ok(0); + }; + + let read = reader.read(buf)?; + self.position += read as u64; + + if read == 0 { + self.reader = None; + self.reached_eof = true; + } + + Ok(read) + } +} + +impl MediaSource for HttpAudioReader { + fn is_seekable(&self) -> bool { + true + } + + fn byte_len(&self) -> Option { + self.byte_len + } +} + +impl Seek for HttpAudioReader { + fn seek(&mut self, position: SeekFrom) -> std::io::Result { + let next = match position { + SeekFrom::Start(offset) => offset as i128, + SeekFrom::Current(offset) => self.position as i128 + offset as i128, + SeekFrom::End(offset) => match self.byte_len { + Some(byte_len) => byte_len as i128 + offset as i128, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Cannot seek from end without a known content length", + )); + } + }, + }; + + if next < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Cannot seek before the start of the stream", + )); + } + + let next = next as u64; + + if next != self.position { + self.position = next; + self.reader = None; + self.reached_eof = false; + } + + Ok(self.position) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + fn spawn_http_server( + responses: Vec<(String, Vec)>, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let (request_tx, request_rx) = mpsc::channel(); + + let handle = thread::spawn(move || { + for (head, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + + loop { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|chunk| chunk == b"\r\n\r\n") { + break; + } + } + + request_tx + .send(String::from_utf8_lossy(&request).into_owned()) + .unwrap(); + + let response = format!( + "{head}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + + (base_url, request_rx, handle) + } + + #[test] + fn fetch_remote_source_descriptor_falls_back_to_plain_request() { + let responses = vec![ + ("HTTP/1.1 416 Range Not Satisfiable".to_string(), Vec::new()), + ( + "HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg".to_string(), + b"abcde".to_vec(), + ), + ]; + let (url, request_rx, handle) = spawn_http_server(responses); + + let descriptor = fetch_remote_source_descriptor(&url).unwrap(); + let first_request = request_rx.recv().unwrap(); + let second_request = request_rx.recv().unwrap(); + handle.join().unwrap(); + + assert!(first_request.contains("Range: bytes=0-0")); + assert!(!second_request.contains("Range:")); + assert_eq!(descriptor.byte_len, Some(5)); + assert_eq!(descriptor.mime_type.as_deref(), Some("audio/mpeg")); + } + + #[test] + fn open_http_stream_skips_bytes_when_server_ignores_range() { + let responses = vec![("HTTP/1.1 200 OK".to_string(), b"abcdef".to_vec())]; + let (url, request_rx, handle) = spawn_http_server(responses); + + let (mut reader, byte_len) = open_http_stream(&url, 2).unwrap(); + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).unwrap(); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + + assert!(request.contains("Range: bytes=2-")); + assert_eq!(byte_len, Some(6)); + assert_eq!(bytes, b"cdef"); + } +} diff --git a/crates/audio-player/src/player/mod.rs b/crates/audio-player/src/player/mod.rs new file mode 100644 index 0000000..dd5101a --- /dev/null +++ b/crates/audio-player/src/player/mod.rs @@ -0,0 +1,675 @@ +mod http; +mod source; +mod stretch; +mod symphonia; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink}; +use rodio::{Player, Sample, Source}; +use tracing::warn; + +use self::source::{SourceDescriptor, load_source_descriptor, open_source_at}; + +use crate::error::{Error, Result}; +use crate::models::{AudioActionResponse, AudioMetadata, PlaybackStatus, PlayerState, TimeUpdate}; +use crate::{OnChanged, OnTimeUpdate, transitions}; + +/// Audio player backed by Rodio for cross-platform desktop playback. +/// +/// Manages audio output, a playback monitor for time updates +/// and end-of-track detection, and a state machine matching the plugin's +/// [`PlaybackStatus`] model. +pub struct RodioAudioPlayer { + inner: Arc>, + output_sink: MixerDeviceSink, + on_changed: OnChanged, + on_time_update: OnTimeUpdate, +} + +struct Inner { + state: PlayerState, + playback: Option, + monitor_stop: Arc, + load_generation: u64, + seek_generation: u64, +} + +struct PlaybackContext { + sink: Player, + source: SourceDescriptor, + duration: f64, + position_offset: f64, + supports_direct_seek: bool, +} + +struct OpenedPlaybackSource { + source: Box + Send>, + duration: f64, + supports_direct_seek: bool, +} + +impl RodioAudioPlayer { + /// Creates a new Rodio-backed audio player. + /// + /// Opens the default audio output device. Returns an error + /// if no audio device is available. + pub fn new(on_changed: OnChanged, on_time_update: OnTimeUpdate) -> Result { + let mut output_sink = open_audio_output()?; + output_sink.log_on_drop(false); + + Ok(Self { + inner: Arc::new(Mutex::new(Inner { + state: PlayerState::default(), + playback: None, + monitor_stop: Arc::new(AtomicBool::new(true)), + load_generation: 0, + seek_generation: 0, + })), + output_sink, + on_changed, + on_time_update, + }) + } + + /// Stops the monitor thread by setting the flag. + fn stop_monitor(inner: &Inner) { + inner.monitor_stop.store(true, Ordering::Relaxed); + } + + /// Spawns a new monitor thread for time updates and end-of-track detection. + /// + /// The old monitor thread may briefly overlap (up to 250ms) until it + /// observes the stop flag on its next poll. This is harmless — any + /// duplicate time updates are benign, and the state is already updated + /// under the mutex before the new monitor starts, so the old one cannot + /// trigger a spurious Ended transition. + fn start_monitor(&self, inner: &mut Inner) { + let stop = Arc::new(AtomicBool::new(false)); + inner.monitor_stop = stop.clone(); + + let inner_arc = Arc::clone(&self.inner); + let on_changed = Arc::clone(&self.on_changed); + let on_time_update = Arc::clone(&self.on_time_update); + + if let Err(e) = std::thread::Builder::new() + .name("audio-monitor".into()) + .spawn(move || { + monitor_loop(stop, inner_arc, on_changed, on_time_update); + }) + { + warn!("Failed to spawn audio monitor thread: {e}"); + } + } + + pub fn get_state(&self) -> PlayerState { + lock_inner(&self.inner).state.clone() + } + + pub fn load( + &self, + src: &str, + metadata: Option, + ) -> Result { + let meta = metadata.unwrap_or_default(); + + let (load_generation, playback_rate) = { + let mut inner = lock_inner(&self.inner); + transitions::begin_load(&mut inner.state, src, &meta)?; + inner.load_generation = inner.load_generation.wrapping_add(1); + inner.seek_generation = inner.seek_generation.wrapping_add(1); + let load_generation = inner.load_generation; + let playback_rate = inner.state.playback_rate; + let snapshot = inner.state.clone(); + drop(inner); + (self.on_changed)(&snapshot); + (load_generation, playback_rate) + }; + + let result = self.load_inner(src, &meta, load_generation, playback_rate); + + match result { + Ok(snapshot) => { + (self.on_changed)(&snapshot); + Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Ready)) + } + Err(e) => { + let mut inner = lock_inner(&self.inner); + + if inner.load_generation != load_generation { + return Err(Error::InvalidState("Load request was canceled".into())); + } + + transitions::error(&mut inner.state, e.to_string()); + let snapshot = inner.state.clone(); + drop(inner); + (self.on_changed)(&snapshot); + Err(e) + } + } + } + + /// Inner prepare logic that may fail. Separated so `prepare()` can catch errors + /// and transition to the Error state before propagating. + fn load_inner( + &self, + src: &str, + meta: &AudioMetadata, + load_generation: u64, + playback_rate: f64, + ) -> Result { + let descriptor = load_source_descriptor(src)?; + let opened_source = open_playback_source(&descriptor, 0.0, playback_rate)?; + let duration = opened_source.duration; + + // Create a new sink, append the decoded source, and pause immediately + // so playback waits for an explicit play() call. + let sink = Player::connect_new(self.output_sink.mixer()); + sink.pause(); + sink.append(opened_source.source); + + let mut inner = lock_inner(&self.inner); + + if inner.load_generation != load_generation { + return Err(Error::InvalidState("Load request was canceled".into())); + } + + transitions::load(&mut inner.state, src, meta, duration)?; + + Self::stop_monitor(&inner); + + sink.set_volume(effective_volume(&inner.state)); + + inner.playback = Some(PlaybackContext { + sink, + source: descriptor, + duration, + position_offset: 0.0, + supports_direct_seek: opened_source.supports_direct_seek, + }); + + Ok(inner.state.clone()) + } + + pub fn play(&self) -> Result { + let snapshot = { + let mut inner = lock_inner(&self.inner); + let is_ended = inner.state.status == PlaybackStatus::Ended; + let mut replayed_from_start = false; + let playback_rate = inner.state.playback_rate; + + if is_ended + && let Some(ctx) = &mut inner.playback + && ctx.sink.empty() + { + let opened_source = open_playback_source(&ctx.source, 0.0, playback_rate)?; + ctx.sink.append(opened_source.source); + ctx.sink.pause(); + ctx.position_offset = 0.0; + ctx.supports_direct_seek = opened_source.supports_direct_seek; + replayed_from_start = true; + } + + transitions::play(&mut inner.state)?; + + if replayed_from_start { + inner.state.current_time = 0.0; + } + + if let Some(ctx) = &inner.playback { + ctx.sink.play(); + } + + self.start_monitor(&mut inner); + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Playing)) + } + + pub fn pause(&self) -> Result { + let snapshot = { + let mut inner = lock_inner(&self.inner); + + transitions::pause(&mut inner.state)?; + + if let Some(ctx) = &inner.playback { + ctx.sink.pause(); + } + + Self::stop_monitor(&inner); + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Paused)) + } + + pub fn stop(&self) -> Result { + let snapshot = { + let mut inner = lock_inner(&self.inner); + + transitions::stop(&mut inner.state)?; + inner.load_generation = inner.load_generation.wrapping_add(1); + inner.seek_generation = inner.seek_generation.wrapping_add(1); + + Self::stop_monitor(&inner); + + if let Some(ctx) = inner.playback.take() { + ctx.sink.stop(); + } + + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + Ok(AudioActionResponse::new(snapshot, PlaybackStatus::Idle)) + } + + pub fn seek(&self, position: f64) -> Result { + enum SeekAction { + Complete(PlayerState), + Remote { + source_descriptor: SourceDescriptor, + duration: f64, + target_time: f64, + previous_time: f64, + seek_generation: u64, + }, + } + + let snapshot = { + let mut inner = lock_inner(&self.inner); + let was_ended = inner.state.status == PlaybackStatus::Ended; + let previous_time = inner.state.current_time; + + transitions::seek(&mut inner.state, position)?; + inner.seek_generation = inner.seek_generation.wrapping_add(1); + let seek_generation = inner.seek_generation; + + let action = if let Some((source_descriptor, duration, supports_direct_seek)) = inner + .playback + .as_ref() + .map(|ctx| (ctx.source.clone(), ctx.duration, ctx.supports_direct_seek)) + { + if supports_direct_seek { + Self::seek_local_playback(&mut inner, &source_descriptor, was_ended, previous_time)?; + SeekAction::Complete(inner.state.clone()) + } else { + Self::stop_monitor(&inner); + if let Some(ctx) = &inner.playback { + ctx.sink.pause(); + } + + SeekAction::Remote { + source_descriptor, + duration, + target_time: inner.state.current_time, + previous_time, + seek_generation, + } + } + } else { + SeekAction::Complete(inner.state.clone()) + }; + + match action { + SeekAction::Complete(snapshot) => snapshot, + SeekAction::Remote { + source_descriptor, + duration, + target_time, + previous_time, + seek_generation, + } => { + drop(inner); + self.reopen_playback( + source_descriptor, + duration, + target_time, + previous_time, + seek_generation, + )? + } + } + }; + + let expected = snapshot.status; + (self.on_changed)(&snapshot); + Ok(AudioActionResponse::new(snapshot, expected)) + } + + fn reopen_playback( + &self, + source_descriptor: SourceDescriptor, + duration: f64, + target_time: f64, + previous_time: f64, + seek_generation: u64, + ) -> Result { + let playback_rate = lock_inner(&self.inner).state.playback_rate; + let source = open_playback_source(&source_descriptor, target_time, playback_rate); + + let opened_source = match source { + Ok(source) => source, + Err(error) => { + let mut inner = lock_inner(&self.inner); + + if inner.seek_generation == seek_generation { + inner.state.current_time = previous_time; + + if let Some(ctx) = &inner.playback { + ctx.sink.set_volume(effective_volume(&inner.state)); + if inner.state.status == PlaybackStatus::Playing { + ctx.sink.play(); + } + } + + if inner.state.status == PlaybackStatus::Playing { + self.start_monitor(&mut inner); + } + } + + return Err(error); + } + }; + + let sink = Player::connect_new(self.output_sink.mixer()); + sink.pause(); + sink.append(opened_source.source); + + let mut inner = lock_inner(&self.inner); + + if inner.seek_generation != seek_generation { + sink.stop(); + return Err(Error::InvalidState("Seek request was canceled".into())); + } + + sink.set_volume(effective_volume(&inner.state)); + + if let Some(previous_playback) = inner.playback.replace(PlaybackContext { + sink, + source: source_descriptor, + duration, + position_offset: target_time, + supports_direct_seek: opened_source.supports_direct_seek, + }) { + previous_playback.sink.stop(); + } + + if inner.state.status == PlaybackStatus::Playing { + if let Some(ctx) = &inner.playback { + ctx.sink.play(); + } + self.start_monitor(&mut inner); + } + + Ok(inner.state.clone()) + } + + fn seek_local_playback( + inner: &mut Inner, + source_descriptor: &SourceDescriptor, + was_ended: bool, + previous_time: f64, + ) -> Result<()> { + let target_time = inner.state.current_time; + let playback_rate = inner.state.playback_rate; + let Some(ctx) = &mut inner.playback else { + unreachable!("Playback context disappeared during local seek"); + }; + let mut reopened_source = false; + + if was_ended && ctx.sink.empty() { + let opened_source = match open_playback_source(source_descriptor, 0.0, playback_rate) { + Ok(source) => source, + Err(error) => { + inner.state.current_time = previous_time; + return Err(error); + } + }; + ctx.sink.append(opened_source.source); + ctx.sink.pause(); + ctx.supports_direct_seek = opened_source.supports_direct_seek; + reopened_source = true; + } + + if let Err(e) = ctx + .sink + .try_seek(Duration::from_secs_f64(inner.state.current_time)) + { + if reopened_source { + ctx.sink.stop(); + } + inner.state.current_time = previous_time; + return Err(Error::Audio(format!("Failed to seek audio: {e}"))); + } + + ctx.position_offset = target_time; + + Ok(()) + } + + pub fn set_volume(&self, level: f64) -> Result { + let snapshot = { + let mut inner = lock_inner(&self.inner); + transitions::set_volume(&mut inner.state, level)?; + if let Some(ctx) = &inner.playback { + ctx.sink.set_volume(effective_volume(&inner.state)); + } + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + Ok(snapshot) + } + + pub fn set_muted(&self, muted: bool) -> PlayerState { + let snapshot = { + let mut inner = lock_inner(&self.inner); + transitions::set_muted(&mut inner.state, muted); + if let Some(ctx) = &inner.playback { + ctx.sink.set_volume(effective_volume(&inner.state)); + } + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + snapshot + } + + pub fn set_playback_rate(&self, rate: f64) -> Result { + enum PlaybackRateAction { + Complete(PlayerState), + Reopen { + source_descriptor: SourceDescriptor, + duration: f64, + target_time: f64, + previous_time: f64, + seek_generation: u64, + }, + } + + let action = { + let mut inner = lock_inner(&self.inner); + let previous_time = inner.state.current_time; + + transitions::set_playback_rate(&mut inner.state, rate)?; + + if let Some((source_descriptor, duration)) = inner + .playback + .as_ref() + .map(|ctx| (ctx.source.clone(), ctx.duration)) + { + inner.seek_generation = inner.seek_generation.wrapping_add(1); + let seek_generation = inner.seek_generation; + + Self::stop_monitor(&inner); + if let Some(ctx) = &inner.playback { + ctx.sink.pause(); + } + + PlaybackRateAction::Reopen { + source_descriptor, + duration, + target_time: inner.state.current_time, + previous_time, + seek_generation, + } + } else { + PlaybackRateAction::Complete(inner.state.clone()) + } + }; + + let snapshot = match action { + PlaybackRateAction::Complete(snapshot) => snapshot, + PlaybackRateAction::Reopen { + source_descriptor, + duration, + target_time, + previous_time, + seek_generation, + } => self.reopen_playback( + source_descriptor, + duration, + target_time, + previous_time, + seek_generation, + )?, + }; + + (self.on_changed)(&snapshot); + Ok(snapshot) + } + + pub fn set_loop(&self, looping: bool) -> PlayerState { + let snapshot = { + let mut inner = lock_inner(&self.inner); + transitions::set_loop(&mut inner.state, looping); + inner.state.clone() + }; + + (self.on_changed)(&snapshot); + snapshot + } +} + +// --------------------------------------------------------------------------- +// Audio output +// --------------------------------------------------------------------------- + +/// Opens the default audio output device for playback. +fn open_audio_output() -> Result { + DeviceSinkBuilder::open_default_sink() + .map_err(|e| Error::Audio(format!("Failed to open audio device: {e}"))) +} + +// --------------------------------------------------------------------------- +// Playback monitor +// --------------------------------------------------------------------------- + +/// Polls the sink every 250ms for position updates and end-of-track detection. +fn monitor_loop( + stop: Arc, + inner: Arc>, + on_changed: OnChanged, + on_time_update: OnTimeUpdate, +) { + loop { + std::thread::sleep(Duration::from_millis(250)); + + if stop.load(Ordering::Relaxed) { + break; + } + + let mut guard = lock_inner(&inner); + + let (pos, duration, is_empty) = match &guard.playback { + Some(ctx) => { + let pos = ctx.position_offset + (ctx.sink.get_pos().as_secs_f64() * guard.state.playback_rate); + (pos, ctx.duration, ctx.sink.empty()) + } + None => break, + }; + + if is_empty { + if guard.state.looping { + // Re-append source for seamless (best-effort) loop. + let playback_rate = guard.state.playback_rate; + if let Some(ctx) = &mut guard.playback { + match open_playback_source(&ctx.source, 0.0, playback_rate) { + Ok(source) => { + ctx.sink.append(source.source); + ctx.position_offset = 0.0; + ctx.supports_direct_seek = source.supports_direct_seek; + } + Err(e) => warn!("Failed to reopen loop source: {e}"), + } + } + guard.state.current_time = 0.0; + drop(guard); + on_time_update(&TimeUpdate { + current_time: 0.0, + duration, + }); + } else { + guard.state.status = PlaybackStatus::Ended; + guard.state.current_time = if duration > 0.0 { duration } else { pos }; + let snapshot = guard.state.clone(); + drop(guard); + on_changed(&snapshot); + break; + } + } else { + guard.state.current_time = pos; + drop(guard); + on_time_update(&TimeUpdate { + current_time: pos, + duration, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Acquires the mutex, recovering from poisoning instead of panicking. +/// +/// A poisoned mutex means a thread panicked while holding the lock. The inner +/// data may be in an inconsistent state, but for an audio player the worst case +/// is a glitched playback state — far better than crashing the host application. +fn lock_inner(mutex: &Mutex) -> MutexGuard<'_, Inner> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn open_playback_source( + source_descriptor: &SourceDescriptor, + position: f64, + playback_rate: f64, +) -> Result { + let opened_source = open_source_at(source_descriptor, position, playback_rate)?; + let duration = opened_source + .duration + .map(|value| value.as_secs_f64()) + .unwrap_or(0.0); + + Ok(OpenedPlaybackSource { + source: opened_source.source, + duration, + supports_direct_seek: opened_source.supports_direct_seek, + }) +} + +/// Resolves the effective sink volume, accounting for the mute flag. +fn effective_volume(state: &PlayerState) -> f32 { + if state.muted { + 0.0 + } else { + state.volume as f32 + } +} diff --git a/crates/audio-player/src/player/source.rs b/crates/audio-player/src/player/source.rs new file mode 100644 index 0000000..7637645 --- /dev/null +++ b/crates/audio-player/src/player/source.rs @@ -0,0 +1,87 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use rodio::{Sample, Source}; + +use super::http::{RemoteSourceDescriptor, fetch_remote_source_descriptor}; +use super::stretch::StretchSource; +use super::symphonia::SymphoniaSource; + +use crate::error::{Error, Result}; +use crate::net::reject_private_host; + +#[derive(Clone)] +pub(crate) enum SourceDescriptor { + Local { path: PathBuf }, + Remote(RemoteSourceDescriptor), +} + +pub(crate) type BoxedSource = Box + Send>; + +pub(crate) struct OpenedSource { + pub(crate) source: BoxedSource, + pub(crate) duration: Option, + pub(crate) supports_direct_seek: bool, +} + +pub(crate) fn load_source_descriptor(src: &str) -> Result { + if src.starts_with("http://") || src.starts_with("https://") { + reject_private_host(src)?; + return Ok(SourceDescriptor::Remote(fetch_remote_source_descriptor( + src, + )?)); + } + + if src.contains("://") || src.starts_with("data:") { + return Err(Error::Http(format!("Unsupported URL scheme: {src}"))); + } + + Ok(SourceDescriptor::Local { + path: PathBuf::from(src), + }) +} + +pub(crate) fn open_source_at( + source: &SourceDescriptor, + position: f64, + playback_rate: f64, +) -> Result { + let supports_direct_seek = matches!(source, SourceDescriptor::Local { .. }) + && (playback_rate - 1.0).abs() <= f64::EPSILON; + let decoded_source: BoxedSource = match source { + SourceDescriptor::Local { path } => Box::new(SymphoniaSource::new_local( + path, + Duration::from_secs_f64(position.max(0.0)), + )?), + SourceDescriptor::Remote(remote) => Box::new(SymphoniaSource::new_remote( + remote, + Duration::from_secs_f64(position.max(0.0)), + )?), + }; + let duration = decoded_source.total_duration(); + let source = if (playback_rate - 1.0).abs() > f64::EPSILON { + Box::new(StretchSource::new(decoded_source, playback_rate)) as BoxedSource + } else { + decoded_source + }; + + Ok(OpenedSource { + source, + duration, + supports_direct_seek, + }) +} + +pub(crate) fn infer_hint(src: &str) -> Option { + let path = src.split('?').next().unwrap_or(src); + let path = path.split('#').next().unwrap_or(path); + + infer_hint_from_path(Path::new(path)) +} + +pub(crate) fn infer_hint_from_path(path: &Path) -> Option { + path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) +} diff --git a/crates/audio-player/src/player/stretch.rs b/crates/audio-player/src/player/stretch.rs new file mode 100644 index 0000000..e803b86 --- /dev/null +++ b/crates/audio-player/src/player/stretch.rs @@ -0,0 +1,174 @@ +use std::sync::Arc; +use std::time::Duration; + +use rodio::source::SeekError as RodioSeekError; +use rodio::{Sample, Source}; +use signalsmith_stretch::Stretch; + +pub(crate) struct StretchSource { + inner: Box + Send>, + stretch: Stretch, + channels: rodio::ChannelCount, + sample_rate: rodio::SampleRate, + total_duration: Option, + playback_rate: f64, + input_latency_frames: usize, + output_latency_frames: usize, + chunk_frames: usize, + input_buffer: Vec, + output_buffer: Vec, + output_index: usize, + input_exhausted: bool, + flushed: bool, +} + +impl StretchSource { + pub(crate) fn new(inner: Box + Send>, playback_rate: f64) -> Self { + let channels = inner.channels(); + let sample_rate = inner.sample_rate(); + let total_duration = inner.total_duration(); + let stretch = Stretch::preset_default(channels.get() as u32, sample_rate.get()); + let input_latency_frames = stretch.input_latency().max(1); + let output_latency_frames = stretch.output_latency().max(1); + let chunk_frames = input_latency_frames.saturating_add(output_latency_frames).max(1); + + Self { + inner, + stretch, + channels, + sample_rate, + total_duration, + playback_rate, + input_latency_frames, + output_latency_frames, + chunk_frames, + input_buffer: Vec::new(), + output_buffer: Vec::new(), + output_index: 0, + input_exhausted: false, + flushed: false, + } + } + + fn channel_count(&self) -> usize { + self.channels.get() as usize + } + + fn output_chunk_frames(&self) -> usize { + self.chunk_frames + } + + fn input_chunk_frames(&self) -> usize { + let scaled_frames = (self.output_chunk_frames() as f64 * self.playback_rate).ceil() as usize; + scaled_frames.max(self.input_latency_frames).max(1) + } + + fn refill_output_buffer(&mut self) -> bool { + if self.output_index < self.output_buffer.len() { + return true; + } + + self.output_buffer.clear(); + self.output_index = 0; + + if self.flushed { + return false; + } + + let input_frames = self.read_input_frames(self.input_chunk_frames()); + + if input_frames == 0 { + if !self.input_exhausted { + return false; + } + + let flush_frames = self.output_latency_frames; + self.output_buffer.resize(flush_frames * self.channel_count(), 0.0); + self.stretch.flush(&mut self.output_buffer); + self.flushed = true; + return true; + } + + let output_frames = ((input_frames as f64) / self.playback_rate).round() as usize; + let output_frames = output_frames.max(1); + self.output_buffer.resize(output_frames * self.channel_count(), 0.0); + self.stretch.process(&self.input_buffer, &mut self.output_buffer); + + true + } + + fn read_input_frames(&mut self, frame_count: usize) -> usize { + let sample_count = frame_count * self.channel_count(); + + self.input_buffer.clear(); + self.input_buffer.reserve(sample_count); + + while self.input_buffer.len() < sample_count { + let Some(sample) = self.inner.next() else { + self.input_exhausted = true; + break; + }; + + self.input_buffer.push(sample); + } + + self.input_buffer.len() / self.channel_count() + } + + fn reset_pipeline(&mut self) { + self.stretch.reset(); + self.input_buffer.clear(); + self.output_buffer.clear(); + self.output_index = 0; + self.input_exhausted = false; + self.flushed = false; + } +} + +impl Iterator for StretchSource { + type Item = Sample; + + fn next(&mut self) -> Option { + while self.output_index >= self.output_buffer.len() { + if !self.refill_output_buffer() { + return None; + } + + if self.output_buffer.is_empty() { + return None; + } + } + + let sample = self.output_buffer.get(self.output_index).copied(); + self.output_index += 1; + sample + } +} + +impl Source for StretchSource { + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> rodio::ChannelCount { + self.channels + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + self.total_duration + } + + fn try_seek(&mut self, position: Duration) -> std::result::Result<(), RodioSeekError> { + self + .inner + .try_seek(position) + .map_err(|error| RodioSeekError::Other(Arc::new(std::io::Error::other(error.to_string()))))?; + + self.reset_pipeline(); + Ok(()) + } +} diff --git a/crates/audio-player/src/player/symphonia.rs b/crates/audio-player/src/player/symphonia.rs new file mode 100644 index 0000000..8b0f9fe --- /dev/null +++ b/crates/audio-player/src/player/symphonia.rs @@ -0,0 +1,319 @@ +use std::fs::File; +use std::num::{NonZeroU16, NonZeroU32}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use rodio::source::SeekError as RodioSeekError; +use rodio::{Sample, Source}; +use symphonia::core::audio::SampleBuffer as SymphoniaSampleBuffer; +use symphonia::core::codecs::{CODEC_TYPE_NULL, Decoder as SymphoniaDecoderTrait, DecoderOptions}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{ + FormatOptions, FormatReader as SymphoniaFormatReader, SeekMode, SeekTo, +}; +use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::core::units::{Time, TimeBase}; +use tracing::warn; + +use super::http::{HttpAudioReader, RemoteSourceDescriptor}; +use super::source::infer_hint_from_path; + +use crate::error::{Error, Result}; + +type SymphoniaDecoder = Box; +type SymphoniaFormat = Box; + +pub(crate) struct SymphoniaSource { + format: SymphoniaFormat, + decoder: SymphoniaDecoder, + track_id: u32, + channels: NonZeroU16, + sample_rate: NonZeroU32, + total_duration: Option, + time_base: TimeBase, + sample_buffer: Option>, + pending_samples: Vec, + pending_index: usize, + pending_seek_ts: Option, + exhausted: bool, +} + +impl SymphoniaSource { + pub(crate) fn new_local(path: &Path, start_time: Duration) -> Result { + let media_source = File::open(path).map_err(Error::Io)?; + let hint = infer_hint_from_path(path); + Self::new(Box::new(media_source), hint.as_deref(), None, start_time) + } + + pub(crate) fn new_remote(remote: &RemoteSourceDescriptor, start_time: Duration) -> Result { + let media_source = HttpAudioReader::new(remote.url.clone(), remote.byte_len); + Self::new( + Box::new(media_source), + remote.hint.as_deref(), + remote.mime_type.as_deref(), + start_time, + ) + } + + fn new( + media_source: Box, + extension_hint: Option<&str>, + mime_type_hint: Option<&str>, + start_time: Duration, + ) -> Result { + let mut hint = Hint::new(); + if let Some(extension) = extension_hint { + hint.with_extension(extension); + } + if let Some(mime_type) = mime_type_hint { + hint.mime_type(mime_type); + } + + let stream = MediaSourceStream::new(media_source, MediaSourceStreamOptions::default()); + let format_options = FormatOptions { + enable_gapless: true, + ..FormatOptions::default() + }; + let probed = symphonia::default::get_probe() + .format(&hint, stream, &format_options, &MetadataOptions::default()) + .map_err(|error| Error::Audio(format!("Failed to probe audio source: {error}")))?; + let format = probed.format; + let track = format + .default_track() + .cloned() + .or_else(|| format.tracks().first().cloned()) + .ok_or_else(|| Error::Audio("Audio source contained no playable tracks".into()))?; + + if track.codec_params.codec == CODEC_TYPE_NULL { + return Err(Error::Audio("Audio track has no supported codec".into())); + } + + let decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|error| Error::Audio(format!("Failed to open audio decoder: {error}")))?; + let sample_rate = NonZeroU32::new(track.codec_params.sample_rate.unwrap_or(44_100)) + .unwrap_or(NonZeroU32::MIN); + let channels = track + .codec_params + .channels + .and_then(|value| NonZeroU16::new(value.count() as u16)) + .unwrap_or(NonZeroU16::MIN.saturating_add(1)); + let time_base = track + .codec_params + .time_base + .unwrap_or_else(|| TimeBase::new(1, sample_rate.get())); + let total_duration = track + .codec_params + .n_frames + .map(|frames| Duration::from(time_base.calc_time(frames))); + + let mut source = Self { + format, + decoder, + track_id: track.id, + channels, + sample_rate, + total_duration, + time_base, + sample_buffer: None, + pending_samples: Vec::new(), + pending_index: 0, + pending_seek_ts: None, + exhausted: false, + }; + + if start_time > Duration::ZERO { + source.seek_internal(start_time)?; + } + + Ok(source) + } + + fn fill_pending_samples(&mut self) -> Result { + if self.pending_index < self.pending_samples.len() { + return Ok(true); + } + + self.pending_samples.clear(); + self.pending_index = 0; + + while !self.exhausted { + let packet = match self.format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(error)) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + self.exhausted = true; + return Ok(false); + } + Err(SymphoniaError::ResetRequired) => { + self.exhausted = true; + return Err(Error::Audio("Audio format changed unexpectedly".into())); + } + Err(error) => { + self.exhausted = true; + return Err(Error::Audio(format!( + "Failed to read audio packet: {error}" + ))); + } + }; + + if packet.track_id() != self.track_id { + continue; + } + + let packet_ts = packet.ts(); + let packet_end_ts = packet_ts.saturating_add(packet.dur()); + let decoded = match self.decoder.decode(&packet) { + Ok(decoded) => decoded, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(SymphoniaError::IoError(_)) => continue, + Err(SymphoniaError::ResetRequired) => { + self.decoder.reset(); + continue; + } + Err(error) => { + self.exhausted = true; + return Err(Error::Audio(format!( + "Failed to decode audio packet: {error}" + ))); + } + }; + + let spec = *decoded.spec(); + if let Some(channels) = NonZeroU16::new(spec.channels.count() as u16) { + self.channels = channels; + } + if let Some(sample_rate) = NonZeroU32::new(spec.rate) { + self.sample_rate = sample_rate; + } + + let mut trim_start_frames = packet.trim_start as usize; + let trim_end_frames = packet.trim_end as usize; + + if let Some(target_ts) = self.pending_seek_ts { + if packet_end_ts <= target_ts { + continue; + } + + if packet_ts < target_ts { + let delta_duration = Duration::from(self.time_base.calc_time(target_ts - packet_ts)); + let delta_frames = + (delta_duration.as_secs_f64() * self.sample_rate.get() as f64).floor() as usize; + trim_start_frames = trim_start_frames.max(delta_frames); + } + + self.pending_seek_ts = None; + } + + if trim_start_frames + trim_end_frames >= decoded.frames() { + continue; + } + + let required_capacity = decoded.frames() * spec.channels.count(); + if self + .sample_buffer + .as_ref() + .map(|buffer| buffer.capacity() < required_capacity) + .unwrap_or(true) + { + self.sample_buffer = Some(SymphoniaSampleBuffer::new(decoded.capacity() as u64, spec)); + } + + let Some(sample_buffer) = &mut self.sample_buffer else { + continue; + }; + + sample_buffer.copy_interleaved_ref(decoded); + let channel_count = spec.channels.count(); + let trim_start_samples = trim_start_frames * channel_count; + let trim_end_samples = trim_end_frames * channel_count; + let samples = sample_buffer.samples(); + let end_index = samples.len().saturating_sub(trim_end_samples); + + if trim_start_samples >= end_index { + continue; + } + + self + .pending_samples + .extend_from_slice(&samples[trim_start_samples..end_index]); + return Ok(true); + } + + Ok(false) + } + + fn seek_internal(&mut self, position: Duration) -> Result<()> { + let target_position = self + .total_duration + .map(|duration| position.min(duration)) + .unwrap_or(position); + let seeked_to = self + .format + .seek( + SeekMode::Accurate, + SeekTo::Time { + time: Time::from(target_position.as_secs_f64()), + track_id: Some(self.track_id), + }, + ) + .map_err(|error| Error::Audio(format!("Failed to seek audio source: {error}")))?; + + self.decoder.reset(); + self.pending_samples.clear(); + self.pending_index = 0; + self.pending_seek_ts = Some(seeked_to.required_ts); + self.exhausted = false; + + Ok(()) + } +} + +impl Iterator for SymphoniaSource { + type Item = Sample; + + fn next(&mut self) -> Option { + if self.pending_index >= self.pending_samples.len() { + match self.fill_pending_samples() { + Ok(true) => {} + Ok(false) => return None, + Err(error) => { + warn!("Failed to stream audio via Symphonia: {error}"); + return None; + } + } + } + + let sample = *self.pending_samples.get(self.pending_index)?; + self.pending_index += 1; + Some(sample) + } +} + +impl Source for SymphoniaSource { + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> rodio::ChannelCount { + self.channels + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + self.total_duration + } + + fn try_seek(&mut self, position: Duration) -> std::result::Result<(), RodioSeekError> { + self + .seek_internal(position) + .map_err(|error| RodioSeekError::Other(Arc::new(std::io::Error::other(error.to_string())))) + } +} diff --git a/crates/audio-player/src/transitions.rs b/crates/audio-player/src/transitions.rs index 58bb27b..ad02ce1 100644 --- a/crates/audio-player/src/transitions.rs +++ b/crates/audio-player/src/transitions.rs @@ -14,7 +14,7 @@ use crate::models::{AudioMetadata, PlaybackStatus, PlayerState}; /// Transitions to [`PlaybackStatus::Loading`] and stores metadata. /// /// Call this before starting I/O so the frontend can show a loading indicator. -/// After the I/O completes, call [`load`] to finalize the transition to `Ready`. +/// After the I/O completes, call [`prepare`] to finalize the transition to `Ready`. pub fn begin_load(state: &mut PlayerState, src: &str, meta: &AudioMetadata) -> Result<()> { match state.status { PlaybackStatus::Idle | PlaybackStatus::Ended | PlaybackStatus::Error => {} @@ -37,15 +37,16 @@ pub fn begin_load(state: &mut PlayerState, src: &str, meta: &AudioMetadata) -> R Ok(()) } -/// Finalizes a load by transitioning from `Loading` to `Ready` with the -/// decoded duration. Also accepts `Idle`, `Ended`, and `Error` in case -/// `begin_load` was skipped (e.g. instant local file loads). -pub fn load(state: &mut PlayerState, src: &str, meta: &AudioMetadata, duration: f64) -> Result<()> { +/// Finalizes prepare by transitioning from `Loading` to `Ready` with the +/// decoded duration. +pub fn load( + state: &mut PlayerState, + src: &str, + meta: &AudioMetadata, + duration: f64, +) -> Result<()> { match state.status { - PlaybackStatus::Loading - | PlaybackStatus::Idle - | PlaybackStatus::Ended - | PlaybackStatus::Error => {} + PlaybackStatus::Loading => {} _ => { return Err(Error::InvalidState(format!( "Cannot load in {:?} state", @@ -139,14 +140,18 @@ pub fn seek(state: &mut PlayerState, position: f64) -> Result<()> { ))); } } - state.current_time = position.clamp(0.0, state.duration); + state.current_time = if state.duration.is_finite() && state.duration > 0.0 { + position.clamp(0.0, state.duration) + } else { + position.max(0.0) + }; Ok(()) } /// Transitions to [`PlaybackStatus::Error`] with a message. /// -/// Valid from `Loading` (I/O or decode failure during load). Other statuses are -/// left unchanged — callers should only invoke this when a load operation fails +/// Valid from `Loading` (I/O or decode failure during prepare). Other statuses are +/// left unchanged — callers should only invoke this when a prepare operation fails /// after `begin_load` has already moved the state to `Loading`. pub fn error(state: &mut PlayerState, message: String) { if state.status == PlaybackStatus::Loading { @@ -272,7 +277,7 @@ mod tests { assert!(begin_load(&mut s, "a.mp3", &AudioMetadata::default()).is_err()); } - // -- load (finalize) -- + // -- prepare (finalize) -- #[test] fn load_from_loading() { @@ -288,24 +293,21 @@ mod tests { } #[test] - fn load_from_idle() { + fn load_rejected_from_idle() { let mut s = state_with_status(PlaybackStatus::Idle); - load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).unwrap(); - assert_eq!(s.status, PlaybackStatus::Ready); + assert!(load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).is_err()); } #[test] - fn load_from_ended() { + fn load_rejected_from_ended() { let mut s = state_with_status(PlaybackStatus::Ended); - assert!(load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).is_ok()); - assert_eq!(s.status, PlaybackStatus::Ready); + assert!(load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).is_err()); } #[test] - fn load_from_error() { + fn load_rejected_from_error() { let mut s = state_with_status(PlaybackStatus::Error); - assert!(load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).is_ok()); - assert_eq!(s.status, PlaybackStatus::Ready); + assert!(load(&mut s, "a.mp3", &AudioMetadata::default(), 0.0).is_err()); } #[test] @@ -541,6 +543,16 @@ mod tests { assert_eq!(s.current_time, 120.0); } + #[test] + fn seek_does_not_clamp_when_duration_unknown() { + let mut s = state_with_duration(PlaybackStatus::Ready, 0.0); + seek(&mut s, 45.0).unwrap(); + assert_eq!(s.current_time, 45.0); + + seek(&mut s, -5.0).unwrap(); + assert_eq!(s.current_time, 0.0); + } + #[test] fn seek_rejected_from_idle() { let mut s = state_with_status(PlaybackStatus::Idle); diff --git a/examples/tauri-app/src-tauri/Cargo.lock b/examples/tauri-app/src-tauri/Cargo.lock index 636e9f2..792e63f 100644 --- a/examples/tauri-app/src-tauri/Cargo.lock +++ b/examples/tauri-app/src-tauri/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.11.0", @@ -46,9 +46,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -110,6 +110,7 @@ version = "0.1.0" dependencies = [ "rodio", "serde", + "signalsmith-stretch", "symphonia", "thiserror 2.0.18", "tracing", @@ -136,18 +137,20 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", "itertools", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn 2.0.117", ] @@ -322,8 +325,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -392,12 +393,6 @@ dependencies = [ "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" @@ -466,45 +461,46 @@ 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.17" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" dependencies = [ - "bindgen", + "bitflags 2.11.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", - "core-foundation-sys", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", "mach2", - "ndk 0.8.0", + "ndk", "ndk-context", - "oboe", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", ] [[package]] @@ -634,12 +630,125 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dasp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7381b67da416b639690ac77c73b86a7b5e64a29e31d1f75fb3b1102301ef355a" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_signal", + "dasp_slice", + "dasp_window", +] + +[[package]] +name = "dasp_envelope" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec617ce7016f101a87fe85ed44180839744265fae73bb4aa43e7ece1b7668b6" +dependencies = [ + "dasp_frame", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", +] + +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_interpolate" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc975a6563bb7ca7ec0a6c784ead49983a21c24835b0bc96eea11ee407c7486" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + +[[package]] +name = "dasp_peak" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf88559d79c21f3d8523d91250c397f9a15b5fc72fbb3f87fdb0a37b79915bf" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_ring_buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d79e19b89618a543c4adec9c5a347fe378a19041699b3278e616e387511ea1" + +[[package]] +name = "dasp_rms" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c5dcb30b7e5014486e2822537ea2beae50b19722ffe2ed7549ab03774575aa" +dependencies = [ + "dasp_frame", + "dasp_ring_buffer", + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "dasp_signal" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1ab7d01689c6ed4eae3d38fe1cea08cba761573fbd2d592528d55b421077e7" +dependencies = [ + "dasp_envelope", + "dasp_frame", + "dasp_interpolate", + "dasp_peak", + "dasp_ring_buffer", + "dasp_rms", + "dasp_sample", + "dasp_window", +] + +[[package]] +name = "dasp_slice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c7335d58e7baedafa516cb361360ff38d6f4d3f9d9d5ee2a2fc8e27178fa1" +dependencies = [ + "dasp_frame", + "dasp_sample", +] + +[[package]] +name = "dasp_window" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ded7b88821d2ce4e8b842c9f1c86ac911891ab89443cc1de750cae764c5076" +dependencies = [ + "dasp_sample", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1398,12 +1507,6 @@ version = "0.4.3" 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.29.1" @@ -1765,16 +1868,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[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.91" @@ -1842,17 +1935,6 @@ 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" @@ -1941,9 +2023,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -2059,20 +2141,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2082,7 +2150,7 @@ dependencies = [ "bitflags 2.11.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2094,15 +2162,6 @@ 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", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2134,6 +2193,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2151,6 +2220,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2205,6 +2294,54 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2212,7 +2349,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2252,6 +2391,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2305,38 +2445,6 @@ dependencies = [ "objc2-foundation", ] -[[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" @@ -2978,18 +3086,22 @@ dependencies = [ [[package]] name = "rodio" -version = "0.19.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ - "claxon", "cpal", - "hound", - "lewton", - "symphonia", - "thiserror 1.0.69", + "dasp_sample", + "num-rational", + "thiserror 2.0.18", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3144,7 +3256,7 @@ dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.1", "servo_arc 0.4.3", "smallvec", ] @@ -3343,6 +3455,17 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signalsmith-stretch" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae6f10b5532510f65c309c4d868babe3aecf6ce0782678081338311f176fd" +dependencies = [ + "bindgen", + "cc", + "dasp", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3391,7 +3514,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk 0.9.0", + "ndk", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3520,6 +3643,7 @@ dependencies = [ "symphonia-bundle-mp3", "symphonia-codec-aac", "symphonia-codec-pcm", + "symphonia-codec-vorbis", "symphonia-core", "symphonia-format-isomp4", "symphonia-format-ogg", @@ -3572,6 +3696,17 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-core" version = "0.5.5" @@ -3719,9 +3854,9 @@ dependencies = [ "jni", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3731,7 +3866,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3802,7 +3937,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows", ] [[package]] @@ -3929,7 +4064,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", ] [[package]] @@ -3954,7 +4089,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", "wry", ] @@ -4109,21 +4244,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.50.0" @@ -4770,7 +4890,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4794,7 +4914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows 0.61.3", + "windows", "windows-core 0.61.2", ] @@ -4844,16 +4964,6 @@ dependencies = [ "windows-version", ] -[[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" @@ -4876,16 +4986,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[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" @@ -4967,15 +5067,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[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" @@ -5409,7 +5500,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk 0.9.0", + "ndk", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -5427,7 +5518,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/examples/tauri-app/src/App.vue b/examples/tauri-app/src/App.vue index b0b8bb5..b874666 100644 --- a/examples/tauri-app/src/App.vue +++ b/examples/tauri-app/src/App.vue @@ -201,15 +201,15 @@