diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea8c2a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - run: cargo test --workspace + - run: cargo clippy -- -D warnings + - run: cargo fmt --check + + build-wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - uses: PyO3/maturin-action@v1 + with: + args: --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6a225b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/target/ +**/*.rs.bk +*.pdb +*.so +*.dylib +*.dll + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.whl + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..236fcf8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[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.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "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_pager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustify-core" +version = "0.1.0" +dependencies = [ + "cpal", + "crossbeam", + "hound", + "lofty", + "serde", + "serde_json", + "symphonia", + "tempfile", + "walkdir", +] + +[[package]] +name = "rustify-python" +version = "0.1.0" +dependencies = [ + "pyo3", + "rustify-core", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b1cb100 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = ["crates/rustify-core", "bindings/python"] +default-members = ["crates/rustify-core"] +resolver = "2" diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 0000000..f618219 --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rustify-python" +version = "0.1.0" +edition = "2021" + +[lib] +name = "_rustify" +crate-type = ["cdylib"] + +[dependencies] +rustify-core = { path = "../../crates/rustify-core" } +pyo3 = { version = "0.23", features = ["extension-module"] } diff --git a/bindings/python/rustify/__init__.py b/bindings/python/rustify/__init__.py new file mode 100644 index 0000000..a9b55f4 --- /dev/null +++ b/bindings/python/rustify/__init__.py @@ -0,0 +1,5 @@ +"""Rustify — Embedded Rust media player for YoyoPod.""" + +from rustify._rustify import RustifyClient, Track, Playlist + +__all__ = ["RustifyClient", "Track", "Playlist"] diff --git a/bindings/python/rustify/py.typed b/bindings/python/rustify/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/bindings/python/src/client.rs b/bindings/python/src/client.rs new file mode 100644 index 0000000..c585d1b --- /dev/null +++ b/bindings/python/src/client.rs @@ -0,0 +1,272 @@ +use std::path::{Path, PathBuf}; + +use pyo3::prelude::*; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::PlaybackState; +use rustify_core::{metadata, playlist, scanner, types}; + +// --- Python-facing data types --- + +#[pyclass(name = "Track")] +#[derive(Clone)] +pub struct PyTrack { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub artists: Vec, + #[pyo3(get)] + pub album: String, + #[pyo3(get)] + pub length: u64, + #[pyo3(get)] + pub track_no: Option, +} + +#[pymethods] +impl PyTrack { + fn __repr__(&self) -> String { + format!("Track(name={:?}, artists={:?})", self.name, self.artists) + } +} + +impl From for PyTrack { + fn from(t: types::Track) -> Self { + Self { + uri: t.uri, + name: t.name, + artists: t.artists, + album: t.album, + length: t.length, + track_no: t.track_no, + } + } +} + +#[pyclass(name = "Playlist")] +#[derive(Clone)] +pub struct PyPlaylist { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub track_count: usize, +} + +#[pymethods] +impl PyPlaylist { + fn __repr__(&self) -> String { + format!( + "Playlist(name={:?}, track_count={})", + self.name, self.track_count + ) + } +} + +impl From for PyPlaylist { + fn from(p: types::Playlist) -> Self { + Self { + uri: p.uri, + name: p.name, + track_count: p.track_count, + } + } +} + +// --- RustifyClient --- + +#[pyclass] +pub struct RustifyClient { + player: Player, + music_dirs: Vec, +} + +#[pymethods] +impl RustifyClient { + #[new] + #[pyo3(signature = (alsa_device = "default".to_string(), music_dirs = vec![]))] + fn new(alsa_device: String, music_dirs: Vec) -> PyResult { + let dirs: Vec = music_dirs.iter().map(PathBuf::from).collect(); + let player = Player::new(PlayerConfig { + alsa_device, + music_dirs: dirs.clone(), + }) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + Ok(Self { + player, + music_dirs: dirs, + }) + } + + // --- Transport --- + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn next_track(&self) { + self.player.next(); + } + + fn previous_track(&self) { + self.player.previous(); + } + + fn seek(&self, position_ms: u64) { + self.player.seek(position_ms); + } + + // --- Volume --- + + fn set_volume(&self, volume: u8) { + self.player.set_volume(volume); + } + + fn get_volume(&self) -> u8 { + self.player.get_volume() + } + + // --- State queries --- + + fn get_playback_state(&self) -> &'static str { + match self.player.get_playback_state() { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + } + } + + fn get_current_track(&self) -> Option { + self.player.get_current_track().map(PyTrack::from) + } + + fn get_time_position(&self) -> u64 { + self.player.get_time_position() + } + + // --- Tracklist --- + + fn load_track_uris(&self, uris: Vec) { + self.player.load_track_uris(uris); + } + + fn clear_tracklist(&self) { + self.player.clear_tracklist(); + } + + // --- Library --- + + fn browse_library(&self, path: String) -> PyResult> { + let p = types::uri_to_path(&path); + scanner::browse_directory(&p) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + fn scan_library(&self) -> PyResult> { + let mut all_uris = Vec::new(); + for dir in &self.music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => all_uris.extend(uris), + Err(e) => return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), + } + } + all_uris.sort(); + Ok(all_uris) + } + + // --- Playlists --- + + fn get_playlists(&self) -> PyResult> { + let mut all_playlists = Vec::new(); + for dir in &self.music_dirs { + match playlist::find_playlists(dir) { + Ok(pls) => all_playlists.extend(pls.into_iter().map(PyPlaylist::from)), + Err(e) => return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), + } + } + Ok(all_playlists) + } + + fn load_playlist(&self, path: String) -> PyResult<()> { + let uris = playlist::parse_m3u(Path::new(&path)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + self.player.load_track_uris(uris); + Ok(()) + } + + // --- Metadata --- + + fn read_metadata(&self, uri: String) -> PyResult { + metadata::read_metadata(&uri) + .map(PyTrack::from) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + // --- Callbacks --- + + fn on_track_change(&self, callback: PyObject) { + self.player + .on_track_change(Box::new(move |track: types::Track| { + Python::with_gil(|py| { + let py_track = PyTrack::from(track); + if let Err(e) = callback.call1(py, (py_track,)) { + eprintln!("Python on_track_change callback error: {e}"); + } + }); + })); + } + + fn on_state_change(&self, callback: PyObject) { + self.player + .on_state_change(Box::new(move |state: PlaybackState| { + Python::with_gil(|py| { + let state_str = match state { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + }; + if let Err(e) = callback.call1(py, (state_str,)) { + eprintln!("Python on_state_change callback error: {e}"); + } + }); + })); + } + + fn on_position_update(&self, callback: PyObject) { + self.player.on_position_update(Box::new(move |ms: u64| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (ms,)) { + eprintln!("Python on_position_update callback error: {e}"); + } + }); + })); + } + + fn on_error(&self, callback: PyObject) { + self.player.on_error(Box::new(move |msg: String| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (msg,)) { + eprintln!("Python on_error callback error: {e}"); + } + }); + })); + } + + // --- Lifecycle --- + + fn shutdown(&self) { + self.player.shutdown(); + } +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 0000000..959dc0f --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,14 @@ +mod client; + +use pyo3::prelude::*; + +use client::{PyPlaylist, PyTrack, RustifyClient}; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/crates/rustify-core/Cargo.toml b/crates/rustify-core/Cargo.toml new file mode 100644 index 0000000..b9a4327 --- /dev/null +++ b/crates/rustify-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rustify-core" +version = "0.1.0" +edition = "2021" +description = "Embedded Rust media player library for YoyoPod" + +[dependencies] +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "ogg", "wav", "pcm"] } +cpal = "0.15" +crossbeam = "0.8" +walkdir = "2" +lofty = "0.22" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" +hound = "3" +serde_json = "1" + +[[example]] +name = "play" +path = "../../examples/play.rs" + diff --git a/crates/rustify-core/src/error.rs b/crates/rustify-core/src/error.rs new file mode 100644 index 0000000..4b86e74 --- /dev/null +++ b/crates/rustify-core/src/error.rs @@ -0,0 +1,84 @@ +use std::fmt; +use std::io; + +/// Unified error type for all rustify-core operations. +#[derive(Debug)] +pub enum RustifyError { + /// I/O errors (file not found, permission denied, etc.) + Io(io::Error), + /// Audio decoding errors (corrupt file, unsupported codec) + Decode(String), + /// Audio output errors (device not found, ALSA error) + Audio(String), + /// Metadata reading errors (corrupt tags, unsupported format) + Metadata(String), + /// Playlist parsing errors (invalid M3U, missing files) + Playlist(String), +} + +impl fmt::Display for RustifyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Decode(msg) => write!(f, "decode error: {msg}"), + Self::Audio(msg) => write!(f, "audio error: {msg}"), + Self::Metadata(msg) => write!(f, "metadata error: {msg}"), + Self::Playlist(msg) => write!(f, "playlist error: {msg}"), + } + } +} + +impl std::error::Error for RustifyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for RustifyError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +/// Result type alias for rustify-core operations. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_io_error() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "gone")); + assert!(err.to_string().contains("IO error")); + assert!(err.to_string().contains("gone")); + } + + #[test] + fn display_decode_error() { + let err = RustifyError::Decode("bad frame".into()); + assert_eq!(err.to_string(), "decode error: bad frame"); + } + + #[test] + fn from_io_error() { + let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "nope"); + let err: RustifyError = io_err.into(); + assert!(matches!(err, RustifyError::Io(_))); + } + + #[test] + fn error_source_for_io() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "x")); + assert!(std::error::Error::source(&err).is_some()); + } + + #[test] + fn error_source_for_non_io() { + let err = RustifyError::Decode("x".into()); + assert!(std::error::Error::source(&err).is_none()); + } +} diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs new file mode 100644 index 0000000..94f3bcc --- /dev/null +++ b/crates/rustify-core/src/lib.rs @@ -0,0 +1,13 @@ +pub mod error; +pub mod metadata; +pub mod mixer; +pub mod player; +pub mod playlist; +pub mod scanner; +pub mod tracklist; +pub mod types; + +// Re-export primary types at crate root for convenience. +pub use error::{Result, RustifyError}; +pub use player::{Player, PlayerConfig}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; diff --git a/crates/rustify-core/src/metadata.rs b/crates/rustify-core/src/metadata.rs new file mode 100644 index 0000000..079f163 --- /dev/null +++ b/crates/rustify-core/src/metadata.rs @@ -0,0 +1,145 @@ +use std::path::Path; + +use lofty::prelude::*; +use lofty::probe::Probe; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, uri_to_path, Track}; + +/// Read audio metadata from a file URI or plain path. +/// Falls back to filename-derived metadata if tags are missing. +pub fn read_metadata(uri: &str) -> Result { + let path = uri_to_path(uri); + read_metadata_from_path(&path) +} + +/// Read audio metadata from a filesystem path. +pub fn read_metadata_from_path(path: &Path) -> Result { + let tagged_file = Probe::open(path) + .map_err(|e| RustifyError::Metadata(format!("failed to open {}: {e}", path.display())))? + .read() + .map_err(|e| { + RustifyError::Metadata(format!("failed to read tags from {}: {e}", path.display())) + })?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let name = tag + .and_then(|t| t.title().map(|s| s.to_string())) + .unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + + let artists = tag + .and_then(|t| t.artist().map(|s| vec![s.to_string()])) + .unwrap_or_default(); + + let album = tag + .and_then(|t| t.album().map(|s| s.to_string())) + .unwrap_or_default(); + + let track_no = tag.and_then(|t| t.track()); + + let length = tagged_file.properties().duration().as_millis() as u64; + + Ok(Track { + uri: path_to_uri(path), + name, + artists, + album, + length, + track_no, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a minimal valid WAV file (44-byte header + 1 second of silence). + fn create_test_wav() -> NamedTempFile { + let mut file = NamedTempFile::with_suffix(".wav").unwrap(); + let sample_rate: u32 = 44100; + let channels: u16 = 1; + let bits_per_sample: u16 = 16; + let num_samples: u32 = sample_rate; // 1 second + let data_size: u32 = num_samples * (bits_per_sample / 8) as u32 * channels as u32; + let file_size: u32 = 36 + data_size; + + // RIFF header + file.write_all(b"RIFF").unwrap(); + file.write_all(&file_size.to_le_bytes()).unwrap(); + file.write_all(b"WAVE").unwrap(); + // fmt chunk + file.write_all(b"fmt ").unwrap(); + file.write_all(&16u32.to_le_bytes()).unwrap(); // chunk size + file.write_all(&1u16.to_le_bytes()).unwrap(); // PCM format + file.write_all(&channels.to_le_bytes()).unwrap(); + file.write_all(&sample_rate.to_le_bytes()).unwrap(); + let byte_rate = sample_rate * channels as u32 * (bits_per_sample / 8) as u32; + file.write_all(&byte_rate.to_le_bytes()).unwrap(); + let block_align = channels * (bits_per_sample / 8); + file.write_all(&block_align.to_le_bytes()).unwrap(); + file.write_all(&bits_per_sample.to_le_bytes()).unwrap(); + // data chunk + file.write_all(b"data").unwrap(); + file.write_all(&data_size.to_le_bytes()).unwrap(); + // Write silence + let silence = vec![0u8; data_size as usize]; + file.write_all(&silence).unwrap(); + file.flush().unwrap(); + file + } + + #[test] + fn read_metadata_from_wav_falls_back_to_filename() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + + let expected_name = wav + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!(track.name, expected_name); + assert!(track.artists.is_empty()); + assert!(track.album.is_empty()); + } + + #[test] + fn read_metadata_returns_file_uri() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.uri.starts_with("file://")); + } + + #[test] + fn read_metadata_reports_duration() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.length > 900 && track.length < 1100); + } + + #[test] + fn read_metadata_via_uri() { + let wav = create_test_wav(); + let uri = path_to_uri(wav.path()); + let track = read_metadata(&uri).unwrap(); + assert!(track.length > 0); + } + + #[test] + fn read_metadata_nonexistent_file_returns_error() { + let result = read_metadata("file:///nonexistent/song.mp3"); + assert!(result.is_err()); + } +} diff --git a/crates/rustify-core/src/mixer.rs b/crates/rustify-core/src/mixer.rs new file mode 100644 index 0000000..3b3937e --- /dev/null +++ b/crates/rustify-core/src/mixer.rs @@ -0,0 +1,87 @@ +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Lock-free volume control using atomic operations. +/// Volume ranges from 0 (silent) to 100 (full). +pub struct Mixer { + volume: AtomicU8, +} + +impl Mixer { + /// Create a new mixer with the given initial volume (clamped to 0-100). + pub fn new(initial_volume: u8) -> Self { + Self { + volume: AtomicU8::new(initial_volume.min(100)), + } + } + + /// Set the volume (clamped to 0-100). + pub fn set_volume(&self, volume: u8) { + self.volume.store(volume.min(100), Ordering::Relaxed); + } + + /// Get the current volume (0-100). + pub fn get_volume(&self) -> u8 { + self.volume.load(Ordering::Relaxed) + } + + /// Get the gain multiplier (0.0 - 1.0) for applying to audio samples. + pub fn gain(&self) -> f32 { + self.get_volume() as f32 / 100.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_volume() { + let mixer = Mixer::new(75); + assert_eq!(mixer.get_volume(), 75); + } + + #[test] + fn clamps_initial_volume_to_100() { + let mixer = Mixer::new(150); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn set_and_get_volume() { + let mixer = Mixer::new(50); + mixer.set_volume(80); + assert_eq!(mixer.get_volume(), 80); + } + + #[test] + fn clamps_set_volume_to_100() { + let mixer = Mixer::new(50); + mixer.set_volume(200); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn volume_zero() { + let mixer = Mixer::new(0); + assert_eq!(mixer.get_volume(), 0); + assert_eq!(mixer.gain(), 0.0); + } + + #[test] + fn gain_at_full_volume() { + let mixer = Mixer::new(100); + assert!((mixer.gain() - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_half_volume() { + let mixer = Mixer::new(50); + assert!((mixer.gain() - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_zero_volume() { + let mixer = Mixer::new(0); + assert!((mixer.gain() - 0.0).abs() < f32::EPSILON); + } +} diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs new file mode 100644 index 0000000..b7dc13b --- /dev/null +++ b/crates/rustify-core/src/player.rs @@ -0,0 +1,856 @@ +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use crossbeam::channel::{self, Receiver, Sender, TryRecvError}; + +use crate::error::RustifyError; +use crate::metadata::read_metadata_from_path; +use crate::mixer::Mixer; +use crate::tracklist::Tracklist; +use crate::types::{uri_to_path, PlaybackState, PlayerCommand, PlayerEvent, Track}; + +/// Number of audio chunks buffered between decode and output threads. +/// At ~1024 frames per chunk @ 44.1kHz stereo, each chunk is ~23ms. +/// 100 chunks provides ~2.3 seconds of buffer. +const BUFFER_CHUNKS: usize = 100; + +// --- Public API --- + +/// Configuration for creating a Player. +pub struct PlayerConfig { + pub alsa_device: String, + pub music_dirs: Vec, +} + +/// The main player handle. All methods are non-blocking — they send commands +/// to the internal command thread via a crossbeam channel. +pub struct Player { + cmd_tx: Sender, + shared: Arc, + mixer: Arc, + #[allow(dead_code)] // used by Python bindings layer + music_dirs: Vec, + _command_thread: Option>, +} + +impl Player { + /// Create a new player. Spawns the command thread immediately. + /// The output stream is created lazily on first `play()`. + pub fn new(config: PlayerConfig) -> Result { + let (cmd_tx, cmd_rx) = channel::unbounded::(); + let shared = Arc::new(SharedState::new()); + let mixer = Arc::new(Mixer::new(100)); + + let shared_clone = Arc::clone(&shared); + let mixer_clone = Arc::clone(&mixer); + let alsa_device = config.alsa_device.clone(); + + let handle = thread::Builder::new() + .name("rustify-cmd".into()) + .spawn(move || { + let mut cmd_loop = CommandLoop::new(cmd_rx, shared_clone, mixer_clone, alsa_device); + cmd_loop.run(); + }) + .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; + + Ok(Self { + cmd_tx, + shared, + mixer, + music_dirs: config.music_dirs, + _command_thread: Some(handle), + }) + } + + // --- Transport commands (non-blocking, fire-and-forget) --- + + pub fn play(&self) { + self.cmd_tx.send(PlayerCommand::Play).ok(); + } + + pub fn pause(&self) { + self.cmd_tx.send(PlayerCommand::Pause).ok(); + } + + pub fn stop(&self) { + self.cmd_tx.send(PlayerCommand::Stop).ok(); + } + + pub fn next(&self) { + self.cmd_tx.send(PlayerCommand::Next).ok(); + } + + pub fn previous(&self) { + self.cmd_tx.send(PlayerCommand::Previous).ok(); + } + + pub fn seek(&self, position_ms: u64) { + self.cmd_tx.send(PlayerCommand::Seek(position_ms)).ok(); + } + + pub fn set_volume(&self, volume: u8) { + self.mixer.set_volume(volume); + } + + pub fn get_volume(&self) -> u8 { + self.mixer.get_volume() + } + + pub fn load_track_uris(&self, uris: Vec) { + self.cmd_tx.send(PlayerCommand::LoadTrackUris(uris)).ok(); + } + + pub fn clear_tracklist(&self) { + self.cmd_tx.send(PlayerCommand::ClearTracklist).ok(); + } + + pub fn shutdown(&self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } + + // --- State queries (read from shared atomic/mutex state) --- + + pub fn get_playback_state(&self) -> PlaybackState { + self.shared.get_playback_state() + } + + pub fn get_current_track(&self) -> Option { + self.shared.current_track.lock().unwrap().clone() + } + + pub fn get_time_position(&self) -> u64 { + self.shared.time_position_ms.load(Ordering::Relaxed) + } + + // --- Callback registration --- + + pub fn on_state_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_state_change + .push(callback); + } + + pub fn on_track_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_track_change + .push(callback); + } + + pub fn on_position_update(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_position_update + .push(callback); + } + + pub fn on_error(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_error + .push(callback); + } +} + +impl Drop for Player { + fn drop(&mut self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } +} + +// --- Shared State --- + +struct SharedState { + /// Encoded PlaybackState: 0=Stopped, 1=Playing, 2=Paused + playback_state: AtomicU8, + current_track: Mutex>, + time_position_ms: AtomicU64, + callbacks: Mutex, +} + +impl SharedState { + fn new() -> Self { + Self { + playback_state: AtomicU8::new(0), + current_track: Mutex::new(None), + time_position_ms: AtomicU64::new(0), + callbacks: Mutex::new(Callbacks::default()), + } + } + + fn get_playback_state(&self) -> PlaybackState { + match self.playback_state.load(Ordering::Relaxed) { + 1 => PlaybackState::Playing, + 2 => PlaybackState::Paused, + _ => PlaybackState::Stopped, + } + } + + fn set_playback_state(&self, state: PlaybackState) { + let val = match state { + PlaybackState::Stopped => 0, + PlaybackState::Playing => 1, + PlaybackState::Paused => 2, + }; + self.playback_state.store(val, Ordering::Relaxed); + } +} + +#[derive(Default)] +struct Callbacks { + on_state_change: Vec>, + on_track_change: Vec>, + on_position_update: Vec>, + on_error: Vec>, +} + +// --- Internal Events (decode thread -> command loop) --- + +enum InternalEvent { + TrackChanged(Track), + Position(u64), + TrackEnded, + /// Decode thread failed to open/decode the track and exited. + /// Command loop must reset state to Stopped. + DecodeFailed(String), + Error(String), +} + +/// Control messages from command loop to decode thread. +enum DecodeControl { + Pause, + Resume, + Seek(u64), + Stop, +} + +struct DecodeHandle { + control_tx: Sender, + _thread: JoinHandle<()>, +} + +// --- Command Loop (runs on dedicated thread) --- + +struct CommandLoop { + cmd_rx: Receiver, + event_rx: Receiver, + event_tx: Sender, + shared: Arc, + mixer: Arc, + tracklist: Tracklist, + decode_handle: Option, + audio_tx: Sender>, + _audio_stream: Option, + clear_buffer: Arc, + #[allow(dead_code)] + alsa_device: String, +} + +impl CommandLoop { + fn new( + cmd_rx: Receiver, + shared: Arc, + mixer: Arc, + alsa_device: String, + ) -> Self { + let (event_tx, event_rx) = channel::unbounded::(); + let (audio_tx, audio_rx) = channel::bounded::>(BUFFER_CHUNKS); + let clear_buffer = Arc::new(AtomicBool::new(false)); + + // Create the cpal output stream + let stream = create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + + if let Err(ref e) = stream { + eprintln!("rustify: failed to create audio stream: {e}"); + } + + Self { + cmd_rx, + event_rx, + event_tx, + shared, + mixer, + tracklist: Tracklist::new(), + decode_handle: None, + audio_tx, + _audio_stream: stream.ok(), + clear_buffer, + alsa_device, + } + } + + fn run(&mut self) { + loop { + crossbeam::select! { + recv(self.cmd_rx) -> cmd => { + match cmd { + Ok(PlayerCommand::Shutdown) => { + self.stop_decode(); + break; + } + Ok(cmd) => self.handle_command(cmd), + Err(_) => break, // Sender dropped + } + } + recv(self.event_rx) -> event => { + if let Ok(evt) = event { + self.handle_event(evt); + } + } + } + } + } + + fn handle_command(&mut self, cmd: PlayerCommand) { + match cmd { + PlayerCommand::Play => self.handle_play(), + PlayerCommand::Pause => self.handle_pause(), + PlayerCommand::Stop => self.handle_stop(), + PlayerCommand::Next => self.handle_next(), + PlayerCommand::Previous => self.handle_previous(), + PlayerCommand::Seek(ms) => self.handle_seek(ms), + PlayerCommand::SetVolume(vol) => self.mixer.set_volume(vol), + PlayerCommand::LoadTrackUris(uris) => { + self.handle_stop(); + self.tracklist.load(uris); + } + PlayerCommand::ClearTracklist => { + self.handle_stop(); + self.tracklist.clear(); + } + PlayerCommand::Shutdown => unreachable!(), + } + } + + fn handle_event(&mut self, event: InternalEvent) { + match event { + InternalEvent::TrackChanged(track) => { + *self.shared.current_track.lock().unwrap() = Some(track.clone()); + self.emit_callbacks(PlayerEvent::TrackChanged(track)); + } + InternalEvent::Position(ms) => { + self.shared.time_position_ms.store(ms, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::PositionUpdate(ms)); + } + InternalEvent::TrackEnded => { + // Try to advance to next track + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + // End of tracklist — let remaining audio drain naturally. + // Don't call stop_decode() which would clear the buffer + // and cut off the last seconds of audio. + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } + InternalEvent::DecodeFailed(msg) => { + // Decode thread exited without producing audio. + // Reset state so the player doesn't get stuck in Playing. + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::Error(msg)); + } + InternalEvent::Error(msg) => { + self.emit_callbacks(PlayerEvent::Error(msg)); + } + } + } + + fn handle_play(&mut self) { + match self.shared.get_playback_state() { + PlaybackState::Stopped => { + if let Some(uri) = self.tracklist.current() { + let uri = uri.to_string(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + PlaybackState::Paused => { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Resume).ok(); + } + self.set_state(PlaybackState::Playing); + } + PlaybackState::Playing => {} // Already playing + } + } + + fn handle_pause(&mut self) { + if self.shared.get_playback_state() == PlaybackState::Playing { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Pause).ok(); + } + self.set_state(PlaybackState::Paused); + } + } + + fn handle_stop(&mut self) { + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + + fn handle_next(&mut self) { + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } else { + self.handle_stop(); + } + } + + fn handle_previous(&mut self) { + if let Some(uri) = self.tracklist.previous() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + + fn handle_seek(&mut self, ms: u64) { + if let Some(ref handle) = self.decode_handle { + // Clear buffered pre-seek audio so it doesn't keep playing + self.clear_buffer.store(true, Ordering::Relaxed); + handle.control_tx.send(DecodeControl::Seek(ms)).ok(); + } + } + + fn start_decode(&mut self, uri: String) { + // Clear any stale audio in the buffer + self.clear_buffer.store(true, Ordering::Relaxed); + + let (control_tx, control_rx) = channel::unbounded::(); + let audio_tx = self.audio_tx.clone(); + let event_tx = self.event_tx.clone(); + + let handle = thread::Builder::new() + .name("rustify-decode".into()) + .spawn(move || { + decode_thread(uri, audio_tx, control_rx, event_tx); + }) + .expect("failed to spawn decode thread"); + + self.decode_handle = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + } + + fn stop_decode(&mut self) { + if let Some(handle) = self.decode_handle.take() { + handle.control_tx.send(DecodeControl::Stop).ok(); + // Don't join — the thread will exit when it sees Stop or channel disconnect + } + self.clear_buffer.store(true, Ordering::Relaxed); + } + + fn set_state(&mut self, state: PlaybackState) { + self.shared.set_playback_state(state); + self.emit_callbacks(PlayerEvent::StateChanged(state)); + } + + fn emit_callbacks(&self, event: PlayerEvent) { + let callbacks = self.shared.callbacks.lock().unwrap(); + match &event { + PlayerEvent::StateChanged(state) => { + for cb in &callbacks.on_state_change { + cb(*state); + } + } + PlayerEvent::TrackChanged(track) => { + for cb in &callbacks.on_track_change { + cb(track.clone()); + } + } + PlayerEvent::PositionUpdate(ms) => { + for cb in &callbacks.on_position_update { + cb(*ms); + } + } + PlayerEvent::Error(msg) => { + for cb in &callbacks.on_error { + cb(msg.clone()); + } + } + } + } +} + +// --- Decode Thread --- + +fn decode_thread( + uri: String, + audio_tx: Sender>, + control_rx: Receiver, + event_tx: Sender, +) { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::DecoderOptions; + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let path = uri_to_path(&uri); + + // Read metadata for TrackChanged event + match read_metadata_from_path(&path) { + Ok(track) => { + event_tx.send(InternalEvent::TrackChanged(track)).ok(); + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("metadata: {e}"))) + .ok(); + } + } + + // Open file with symphonia + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + event_tx + .send(InternalEvent::DecodeFailed(format!("open: {e}"))) + .ok(); + return; + } + }; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = match symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) { + Ok(p) => p, + Err(e) => { + event_tx + .send(InternalEvent::DecodeFailed(format!("probe: {e}"))) + .ok(); + return; + } + }; + + let mut format = probed.format; + let track = match format.default_track() { + Some(t) => t, + None => { + event_tx + .send(InternalEvent::DecodeFailed("no audio track found".into())) + .ok(); + return; + } + }; + let track_id = track.id; + let time_base = track.codec_params.time_base; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut decoder = match symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + { + Ok(d) => d, + Err(e) => { + event_tx + .send(InternalEvent::DecodeFailed(format!("decoder: {e}"))) + .ok(); + return; + } + }; + + let mut paused = false; + let mut sample_buf: Option> = None; + let mut last_position_report_ms: u64 = 0; + + loop { + // Check for control messages (non-blocking when not paused) + if paused { + // Block until we get a control message + match control_rx.recv() { + Ok(DecodeControl::Resume) => { + paused = false; + continue; + } + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Ok(DecodeControl::Pause) => continue, + Err(_) => break, + } + } else { + match control_rx.try_recv() { + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Pause) => { + paused = true; + continue; + } + Ok(DecodeControl::Resume) => {} + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => break, + } + } + + // Decode next packet + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + event_tx.send(InternalEvent::TrackEnded).ok(); + break; + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("packet: {e}"))) + .ok(); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + // Compute position in milliseconds + let position_ms = if let Some(tb) = time_base { + (packet.ts() as f64 * tb.numer as f64 / tb.denom as f64 * 1000.0) as u64 + } else { + (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64 + }; + + // Report position every ~1 second + if position_ms >= last_position_report_ms + 1000 || position_ms < last_position_report_ms { + event_tx.send(InternalEvent::Position(position_ms)).ok(); + last_position_report_ms = position_ms; + } + + // Decode the packet + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + // Skip corrupt frames + event_tx + .send(InternalEvent::Error(format!("frame: {e}"))) + .ok(); + continue; + } + }; + + // Convert to interleaved f32 and send to output + let sbuf = sample_buf.get_or_insert_with(|| { + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()) + }); + + sbuf.copy_interleaved_ref(decoded); + let chunk = sbuf.samples().to_vec(); + + if audio_tx.send(chunk).is_err() { + break; // Output stream dropped + } + } +} + +fn seek_to( + format: &mut Box, + track_id: u32, + ms: u64, + time_base: Option, + sample_rate: u32, + event_tx: &Sender, +) { + use symphonia::core::formats::{SeekMode, SeekTo}; + + let seek_ts = if let Some(tb) = time_base { + (ms as f64 / 1000.0 * tb.denom as f64 / tb.numer as f64) as u64 + } else { + (ms as f64 / 1000.0 * sample_rate as f64) as u64 + }; + + if let Err(e) = format.seek( + SeekMode::Coarse, + SeekTo::TimeStamp { + ts: seek_ts, + track_id, + }, + ) { + event_tx + .send(InternalEvent::Error(format!("seek: {e}"))) + .ok(); + } +} + +// --- Output Stream (cpal) --- + +fn create_output_stream( + audio_rx: Receiver>, + mixer: Arc, + clear_buffer: Arc, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RustifyError::Audio("no default output device".into()))?; + + let supported_config = device + .default_output_config() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + let device_channels = supported_config.channels() as usize; + + // Force f32 sample format — our decode pipeline outputs f32. + // Override the device default which may be i16/u16 on some ALSA backends. + let config = cpal::StreamConfig { + channels: supported_config.channels(), + sample_rate: supported_config.sample_rate(), + buffer_size: cpal::BufferSize::Default, + }; + + let mut buf: VecDeque = VecDeque::with_capacity(8192); + + // Decoded audio is interleaved stereo (L R L R...). + // The device may have more channels (e.g. 8 on some USB audio). + // We map stereo to the first 2 device channels and silence the rest. + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Check if we should clear stale audio + if clear_buffer.swap(false, Ordering::Relaxed) { + buf.clear(); + while audio_rx.try_recv().is_ok() {} + } + + let gain = mixer.gain(); + + // Process one device frame at a time + for frame in data.chunks_mut(device_channels) { + // Pop one stereo pair (L, R) from decoded audio + let left = if buf.is_empty() { + match audio_rx.try_recv() { + Ok(chunk) => { + buf.extend(chunk); + buf.pop_front().unwrap_or(0.0) + } + Err(_) => 0.0, + } + } else { + buf.pop_front().unwrap_or(0.0) + }; + let right = buf.pop_front().unwrap_or(left); + + // Map stereo to device channels, silence extras + for (i, sample) in frame.iter_mut().enumerate() { + *sample = match i { + 0 => left * gain, + 1 => right * gain, + _ => 0.0, + }; + } + } + }, + |err| { + eprintln!("cpal stream error: {err}"); + }, + None, + ) + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + stream + .play() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_state_initial_values() { + let state = SharedState::new(); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + assert!(state.current_track.lock().unwrap().is_none()); + assert_eq!(state.time_position_ms.load(Ordering::Relaxed), 0); + } + + #[test] + fn shared_state_playback_transitions() { + let state = SharedState::new(); + state.set_playback_state(PlaybackState::Playing); + assert_eq!(state.get_playback_state(), PlaybackState::Playing); + + state.set_playback_state(PlaybackState::Paused); + assert_eq!(state.get_playback_state(), PlaybackState::Paused); + + state.set_playback_state(PlaybackState::Stopped); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + } + + #[test] + fn player_new_starts_in_stopped_state() { + // This test only works if an audio device is available. + // On CI without audio, it will fail at stream creation but the + // command thread will still start with Stopped state. + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + let player = Player::new(config); + if let Ok(player) = player { + assert_eq!(player.get_playback_state(), PlaybackState::Stopped); + assert!(player.get_current_track().is_none()); + assert_eq!(player.get_time_position(), 0); + player.shutdown(); + } + } + + #[test] + fn player_volume_control_bypasses_command_thread() { + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + if let Ok(player) = Player::new(config) { + player.set_volume(75); + assert_eq!(player.get_volume(), 75); + player.shutdown(); + } + } +} diff --git a/crates/rustify-core/src/playlist.rs b/crates/rustify-core/src/playlist.rs new file mode 100644 index 0000000..e061671 --- /dev/null +++ b/crates/rustify-core/src/playlist.rs @@ -0,0 +1,200 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, Playlist, AUDIO_EXTENSIONS}; + +/// Parse an M3U playlist file and return resolved file:// URIs. +/// +/// Handles simple M3U and extended M3U (`#EXTM3U` / `#EXTINF`). +/// Relative paths are resolved against the M3U file's parent directory. +/// Only entries with supported audio extensions are included. +pub fn parse_m3u(path: &Path) -> Result, RustifyError> { + let content = fs::read_to_string(path) + .map_err(|e| RustifyError::Playlist(format!("failed to read {}: {e}", path.display())))?; + + let base_dir = path + .parent() + .ok_or_else(|| RustifyError::Playlist("M3U path has no parent directory".into()))?; + + let mut uris = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Check both OS-native absolute paths and Unix-style /paths + // (the latter handles M3U files from Linux when developing on Windows) + let track_path = if Path::new(line).is_absolute() || line.starts_with('/') { + PathBuf::from(line) + } else { + base_dir.join(line) + }; + + if let Some(ext) = track_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(&track_path)); + } + } + } + + Ok(uris) +} + +/// Find all .m3u playlist files in a directory (non-recursive). +/// Returns metadata about each playlist including track count. +pub fn find_playlists(dir: &Path) -> Result, RustifyError> { + let mut playlists = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some("m3u") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let track_count = parse_m3u(&path).map(|uris| uris.len()).unwrap_or(0); + + playlists.push(Playlist { + uri: path_to_uri(&path), + name, + track_count, + }); + } + } + + Ok(playlists) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_m3u(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn touch(dir: &Path, name: &str) { + fs::write(dir.join(name), b"").unwrap(); + } + + #[test] + fn parse_simple_m3u_absolute_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song1.mp3\n/music/song2.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert!(uris[0].starts_with("file://")); + assert!(uris[0].ends_with("/music/song1.mp3")); + assert!(uris[1].ends_with("/music/song2.flac")); + } + + #[test] + fn parse_m3u_relative_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "songs/track.mp3\n../other/track.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert!(uris[0].contains("songs")); + assert!(uris[1].contains("other")); + } + + #[test] + fn parse_extended_m3u_skips_directives() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "#EXTM3U\n#EXTINF:123,Artist - Title\n/music/song.mp3\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].starts_with("file://")); + assert!(uris[0].ends_with("/music/song.mp3")); + } + + #[test] + fn parse_m3u_skips_blank_lines_and_comments() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "test.m3u", "\n# comment\n\n/music/song.mp3\n\n"); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + } + + #[test] + fn parse_m3u_filters_unsupported_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.mp3\n/music/image.png\n/music/doc.txt\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn parse_m3u_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.MP3\n/music/song.Flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn parse_m3u_nonexistent_file_returns_error() { + let result = parse_m3u(Path::new("/nonexistent/playlist.m3u")); + assert!(result.is_err()); + } + + #[test] + fn parse_empty_m3u() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "empty.m3u", ""); + let uris = parse_m3u(&m3u).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn find_playlists_in_directory() { + let dir = TempDir::new().unwrap(); + create_m3u(dir.path(), "chill.m3u", "/music/a.mp3\n/music/b.flac\n"); + create_m3u(dir.path(), "rock.m3u", "/music/c.ogg\n"); + touch(dir.path(), "readme.txt"); + + let playlists = find_playlists(dir.path()).unwrap(); + assert_eq!(playlists.len(), 2); + + let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"chill")); + assert!(names.contains(&"rock")); + + let chill = playlists.iter().find(|p| p.name == "chill").unwrap(); + assert_eq!(chill.track_count, 2); + } +} diff --git a/crates/rustify-core/src/scanner.rs b/crates/rustify-core/src/scanner.rs new file mode 100644 index 0000000..82be27d --- /dev/null +++ b/crates/rustify-core/src/scanner.rs @@ -0,0 +1,186 @@ +use std::path::Path; + +use walkdir::WalkDir; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, AUDIO_EXTENSIONS}; + +/// Recursively scan a directory for audio files. +/// Returns sorted `file://` URIs for all files matching supported extensions. +pub fn scan_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut uris = Vec::new(); + + for entry in WalkDir::new(path).follow_links(true) { + let entry = entry.map_err(|e| RustifyError::Io(std::io::Error::other(e.to_string())))?; + + if !entry.file_type().is_file() { + continue; + } + + if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(entry.path())); + } + } + } + + uris.sort(); + Ok(uris) +} + +/// List the contents of a single directory (non-recursive). +/// Returns URIs for audio files and subdirectories. +pub fn browse_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut entries = Vec::new(); + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + entries.push(path_to_uri(&entry_path)); + } else if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + entries.push(path_to_uri(&entry_path)); + } + } + } + + entries.sort(); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn touch(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); + } + + #[test] + fn scan_finds_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("track.flac")); + touch(&dir.path().join("sound.ogg")); + touch(&dir.path().join("clip.wav")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 4); + } + + #[test] + fn scan_ignores_non_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("readme.txt")); + touch(&dir.path().join("image.png")); + touch(&dir.path().join("cover.jpg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn scan_recurses_into_subdirectories() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("artist1/album1/track1.mp3")); + touch(&dir.path().join("artist1/album2/track2.flac")); + touch(&dir.path().join("artist2/track3.ogg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 3); + } + + #[test] + fn scan_returns_sorted_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("c.mp3")); + touch(&dir.path().join("a.mp3")); + touch(&dir.path().join("b.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0] < uris[1]); + assert!(uris[1] < uris[2]); + } + + #[test] + fn scan_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("LOUD.MP3")); + touch(&dir.path().join("quiet.Flac")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn scan_returns_file_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0].starts_with("file://")); + } + + #[test] + fn scan_nonexistent_directory_returns_error() { + let result = scan_directory(Path::new("/nonexistent/path")); + assert!(result.is_err()); + } + + #[test] + fn scan_empty_directory() { + let dir = TempDir::new().unwrap(); + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn browse_lists_files_and_dirs() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + fs::create_dir(dir.path().join("subdir")).unwrap(); + touch(&dir.path().join("readme.txt")); + + let entries = browse_directory(dir.path()).unwrap(); + assert_eq!(entries.len(), 2); + } + + #[test] + fn browse_does_not_recurse() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("top.mp3")); + touch(&dir.path().join("sub/nested.mp3")); + + let entries = browse_directory(dir.path()).unwrap(); + assert_eq!(entries.len(), 2); + } +} diff --git a/crates/rustify-core/src/tracklist.rs b/crates/rustify-core/src/tracklist.rs new file mode 100644 index 0000000..ef37709 --- /dev/null +++ b/crates/rustify-core/src/tracklist.rs @@ -0,0 +1,209 @@ +use std::collections::VecDeque; + +/// A playback queue backed by VecDeque. +/// Stores track URIs and maintains a current position index. +pub struct Tracklist { + tracks: VecDeque, + current_index: Option, +} + +impl Tracklist { + pub fn new() -> Self { + Self { + tracks: VecDeque::new(), + current_index: None, + } + } + + /// Append a single track URI to the end of the queue. + pub fn add(&mut self, uri: String) { + self.tracks.push_back(uri); + } + + /// Replace the entire tracklist with the given URIs. + /// Sets the current position to the first track if non-empty. + pub fn load(&mut self, uris: Vec) { + self.tracks.clear(); + self.tracks.extend(uris); + self.current_index = if self.tracks.is_empty() { + None + } else { + Some(0) + }; + } + + /// Remove all tracks and reset position. + pub fn clear(&mut self) { + self.tracks.clear(); + self.current_index = None; + } + + /// Get the URI of the current track, if any. + pub fn current(&self) -> Option<&str> { + self.current_index + .and_then(|i| self.tracks.get(i)) + .map(String::as_str) + } + + /// Advance to the next track and return its URI. + /// Returns None if already at the end. + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx + 1 < self.tracks.len() { + self.current_index = Some(idx + 1); + self.current() + } else { + None + } + } + + /// Go back to the previous track and return its URI. + /// Returns None if already at the beginning. + pub fn previous(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx > 0 { + self.current_index = Some(idx - 1); + self.current() + } else { + None + } + } + + /// Get the current track index (0-based). + pub fn index(&self) -> Option { + self.current_index + } + + /// Get the total number of tracks. + pub fn len(&self) -> usize { + self.tracks.len() + } + + /// Check if the tracklist is empty. + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } +} + +impl Default for Tracklist { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_tracklist_is_empty() { + let tl = Tracklist::new(); + assert!(tl.is_empty()); + assert_eq!(tl.len(), 0); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn add_does_not_set_current() { + let mut tl = Tracklist::new(); + tl.add("file:///a.mp3".into()); + assert_eq!(tl.len(), 1); + assert!(tl.current().is_none()); + } + + #[test] + fn load_sets_current_to_first() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into(), "file:///b.mp3".into()]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.index(), Some(0)); + assert_eq!(tl.current(), Some("file:///a.mp3")); + } + + #[test] + fn load_empty_sets_none() { + let mut tl = Tracklist::new(); + tl.load(vec![]); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + } + + #[test] + fn load_replaces_existing() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.load(vec!["file:///b.mp3".into(), "file:///c.mp3".into()]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.current(), Some("file:///b.mp3")); + } + + #[test] + fn clear_resets_everything() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.clear(); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn next_advances() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + assert_eq!(tl.next(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.next(), Some("file:///c.mp3")); + assert_eq!(tl.index(), Some(2)); + } + + #[test] + fn next_at_end_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.next(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn next_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.next(), None); + } + + #[test] + fn previous_goes_back() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.next(); // -> b + tl.next(); // -> c + assert_eq!(tl.previous(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.previous(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_at_start_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.previous(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.previous(), None); + } +} diff --git a/crates/rustify-core/src/types.rs b/crates/rustify-core/src/types.rs new file mode 100644 index 0000000..504e302 --- /dev/null +++ b/crates/rustify-core/src/types.rs @@ -0,0 +1,137 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Metadata for a single audio track. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Track { + /// File URI (e.g., "file:///path/to/song.mp3") + pub uri: String, + /// Track title (falls back to filename if no tags) + pub name: String, + /// Artist names + pub artists: Vec, + /// Album name + pub album: String, + /// Duration in milliseconds + pub length: u64, + /// Track number within album + pub track_no: Option, +} + +/// Metadata about a playlist file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Playlist { + /// File URI of the .m3u file + pub uri: String, + /// Playlist name (derived from filename) + pub name: String, + /// Number of tracks in the playlist + pub track_count: usize, +} + +/// Playback state of the player. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, +} + +/// Events emitted by the player to registered callbacks. +#[derive(Debug, Clone)] +pub enum PlayerEvent { + StateChanged(PlaybackState), + TrackChanged(Track), + PositionUpdate(u64), + Error(String), +} + +/// Commands sent to the player's command thread. +#[derive(Debug)] +pub enum PlayerCommand { + Play, + Pause, + Stop, + Next, + Previous, + Seek(u64), + SetVolume(u8), + LoadTrackUris(Vec), + ClearTracklist, + Shutdown, +} + +/// Supported audio file extensions. +pub const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Convert a `file://` URI to a filesystem path. +/// Also accepts plain paths (returned as-is). +pub fn uri_to_path(uri: &str) -> PathBuf { + uri.strip_prefix("file://") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(uri)) +} + +/// Convert a filesystem path to a `file://` URI. +pub fn path_to_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn track_creation() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + assert_eq!(track.name, "Song"); + assert_eq!(track.length, 180_000); + } + + #[test] + fn track_serde_roundtrip() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + let json = serde_json::to_string(&track).unwrap(); + let decoded: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(track, decoded); + } + + #[test] + fn uri_to_path_with_scheme() { + let path = uri_to_path("file:///home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn uri_to_path_plain_path() { + let path = uri_to_path("/home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn path_to_uri_conversion() { + let uri = path_to_uri(Path::new("/home/pi/Music/song.mp3")); + assert_eq!(uri, "file:///home/pi/Music/song.mp3"); + } + + #[test] + fn playback_state_equality() { + assert_eq!(PlaybackState::Stopped, PlaybackState::Stopped); + assert_ne!(PlaybackState::Playing, PlaybackState::Paused); + } +} diff --git a/docs/superpowers/plans/2026-04-07-rustify-core.md b/docs/superpowers/plans/2026-04-07-rustify-core.md new file mode 100644 index 0000000..1b36e3f --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-rustify-core.md @@ -0,0 +1,2900 @@ +# Rustify Core Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build rustify-core (pure Rust media player library) and PyO3 Python bindings for the YoyoPod project. + +**Architecture:** Three-thread model (command, decode, output) connected by crossbeam channels. symphonia decodes audio, cpal outputs to ALSA. A lock-free ring buffer (bounded crossbeam channel) decouples decode from output. Exposed to Python via PyO3/maturin as `RustifyClient`. + +**Tech Stack:** Rust (symphonia, cpal, crossbeam, walkdir, lofty, serde), Python (PyO3 0.23 / maturin 1.x) + +**Design Spec:** `docs/specs/2026-04-07-rustify-embedded-player-design.md` + +--- + +## File Map + +### New files (rustify-core) + +| File | Responsibility | +|---|---| +| `Cargo.toml` | Workspace root | +| `crates/rustify-core/Cargo.toml` | Core library dependencies | +| `crates/rustify-core/src/lib.rs` | Module declarations + re-exports | +| `crates/rustify-core/src/error.rs` | `RustifyError` enum, `Result` alias | +| `crates/rustify-core/src/types.rs` | `Track`, `Playlist`, `PlaybackState`, `PlayerEvent`, `PlayerCommand`, URI helpers | +| `crates/rustify-core/src/mixer.rs` | `Mixer` — atomic volume control (0-100) | +| `crates/rustify-core/src/tracklist.rs` | `Tracklist` — VecDeque-backed playback queue | +| `crates/rustify-core/src/playlist.rs` | M3U parser + playlist discovery | +| `crates/rustify-core/src/scanner.rs` | Recursive audio file discovery via walkdir | +| `crates/rustify-core/src/metadata.rs` | Tag reading via lofty, filename fallback | +| `crates/rustify-core/src/player.rs` | Playback engine: command loop, decode thread, cpal output | + +### New files (Python bindings) + +| File | Responsibility | +|---|---| +| `bindings/python/Cargo.toml` | PyO3 cdylib crate | +| `bindings/python/src/lib.rs` | `#[pymodule]` entry point | +| `bindings/python/src/client.rs` | `RustifyClient` pyclass | +| `bindings/python/rustify/__init__.py` | Re-export from native module | +| `bindings/python/rustify/py.typed` | PEP 561 marker | +| `pyproject.toml` | maturin build config | + +### Other files + +| File | Responsibility | +|---|---| +| `examples/play.rs` | Standalone CLI player for hardware testing | +| `.gitignore` | Rust/Python ignores | +| `.github/workflows/ci.yml` | Test + clippy + fmt + wheel build | + +--- + +### Task 1: Scaffold Workspace + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/rustify-core/Cargo.toml` +- Create: `crates/rustify-core/src/lib.rs` +- Create: `bindings/python/Cargo.toml` +- Create: `bindings/python/src/lib.rs` +- Create: `pyproject.toml` +- Create: `.gitignore` +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create workspace root `Cargo.toml`** + +```toml +[workspace] +members = ["crates/rustify-core", "bindings/python"] +default-members = ["crates/rustify-core"] +resolver = "2" +``` + +- [ ] **Step 2: Create `crates/rustify-core/Cargo.toml`** + +```toml +[package] +name = "rustify-core" +version = "0.1.0" +edition = "2021" +description = "Embedded Rust media player library for YoyoPod" + +[dependencies] +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "ogg", "wav", "pcm"] } +cpal = "0.15" +crossbeam = "0.8" +walkdir = "2" +lofty = "0.22" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" +hound = "3" + +[[example]] +name = "play" +path = "../../examples/play.rs" +``` + +Note: cpal and lofty version numbers come from the design spec (April 2026). If `cargo check` fails on versions, check crates.io for the latest compatible version and adjust. + +- [ ] **Step 3: Create `crates/rustify-core/src/lib.rs`** + +```rust +// Modules will be added as they are implemented. +``` + +- [ ] **Step 4: Create `bindings/python/Cargo.toml`** + +```toml +[package] +name = "rustify-python" +version = "0.1.0" +edition = "2021" + +[lib] +name = "_rustify" +crate-type = ["cdylib"] + +[dependencies] +rustify-core = { path = "../../crates/rustify-core" } +pyo3 = { version = "0.23", features = ["extension-module"] } +``` + +- [ ] **Step 5: Create `bindings/python/src/lib.rs`** + +```rust +use pyo3::prelude::*; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} +``` + +- [ ] **Step 6: Create `pyproject.toml`** + +```toml +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "rustify" +version = "0.1.0" +description = "Embedded Rust media player for YoyoPod" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] +manifest-path = "bindings/python/Cargo.toml" +python-source = "bindings/python" +module-name = "rustify._rustify" +``` + +- [ ] **Step 7: Create `.gitignore`** + +``` +/target/ +**/*.rs.bk +*.pdb +*.so +*.dylib +*.dll + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.whl + +# IDE +.idea/ +.vscode/ +*.swp +``` + +- [ ] **Step 8: Create `.github/workflows/ci.yml`** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - run: cargo test --workspace + - run: cargo clippy -- -D warnings + - run: cargo fmt --check + + build-wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - uses: PyO3/maturin-action@v1 + with: + args: --release +``` + +- [ ] **Step 9: Verify workspace compiles** + +Run: `cargo check --workspace` +Expected: Compiles with no errors (warnings are OK at this stage). + +If cpal or lofty version is not found, check crates.io and adjust the version in `crates/rustify-core/Cargo.toml`. + +- [ ] **Step 10: Commit** + +```bash +git add Cargo.toml crates/ bindings/ pyproject.toml .gitignore .github/ +git commit -m "feat: scaffold Cargo workspace with rustify-core and Python bindings" +``` + +--- + +### Task 2: error.rs + types.rs + +**Files:** +- Create: `crates/rustify-core/src/error.rs` +- Create: `crates/rustify-core/src/types.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write tests for error.rs** + +Add to `crates/rustify-core/src/error.rs`: + +```rust +use std::fmt; +use std::io; + +/// Unified error type for all rustify-core operations. +#[derive(Debug)] +pub enum RustifyError { + /// I/O errors (file not found, permission denied, etc.) + Io(io::Error), + /// Audio decoding errors (corrupt file, unsupported codec) + Decode(String), + /// Audio output errors (device not found, ALSA error) + Audio(String), + /// Metadata reading errors (corrupt tags, unsupported format) + Metadata(String), + /// Playlist parsing errors (invalid M3U, missing files) + Playlist(String), +} + +impl fmt::Display for RustifyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Decode(msg) => write!(f, "decode error: {msg}"), + Self::Audio(msg) => write!(f, "audio error: {msg}"), + Self::Metadata(msg) => write!(f, "metadata error: {msg}"), + Self::Playlist(msg) => write!(f, "playlist error: {msg}"), + } + } +} + +impl std::error::Error for RustifyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for RustifyError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +/// Result type alias for rustify-core operations. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_io_error() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "gone")); + assert!(err.to_string().contains("IO error")); + assert!(err.to_string().contains("gone")); + } + + #[test] + fn display_decode_error() { + let err = RustifyError::Decode("bad frame".into()); + assert_eq!(err.to_string(), "decode error: bad frame"); + } + + #[test] + fn from_io_error() { + let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "nope"); + let err: RustifyError = io_err.into(); + assert!(matches!(err, RustifyError::Io(_))); + } + + #[test] + fn error_source_for_io() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "x")); + assert!(std::error::Error::source(&err).is_some()); + } + + #[test] + fn error_source_for_non_io() { + let err = RustifyError::Decode("x".into()); + assert!(std::error::Error::source(&err).is_none()); + } +} +``` + +- [ ] **Step 2: Write types.rs** + +Create `crates/rustify-core/src/types.rs`: + +```rust +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Metadata for a single audio track. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Track { + /// File URI (e.g., "file:///path/to/song.mp3") + pub uri: String, + /// Track title (falls back to filename if no tags) + pub name: String, + /// Artist names + pub artists: Vec, + /// Album name + pub album: String, + /// Duration in milliseconds + pub length: u64, + /// Track number within album + pub track_no: Option, +} + +/// Metadata about a playlist file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Playlist { + /// File URI of the .m3u file + pub uri: String, + /// Playlist name (derived from filename) + pub name: String, + /// Number of tracks in the playlist + pub track_count: usize, +} + +/// Playback state of the player. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, +} + +/// Events emitted by the player to registered callbacks. +#[derive(Debug, Clone)] +pub enum PlayerEvent { + StateChanged(PlaybackState), + TrackChanged(Track), + PositionUpdate(u64), + Error(String), +} + +/// Commands sent to the player's command thread. +#[derive(Debug)] +pub enum PlayerCommand { + Play, + Pause, + Stop, + Next, + Previous, + Seek(u64), + SetVolume(u8), + LoadTrackUris(Vec), + ClearTracklist, + Shutdown, +} + +/// Convert a `file://` URI to a filesystem path. +/// Also accepts plain paths (returned as-is). +pub fn uri_to_path(uri: &str) -> PathBuf { + uri.strip_prefix("file://") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(uri)) +} + +/// Convert a filesystem path to a `file://` URI. +pub fn path_to_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn track_creation() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + assert_eq!(track.name, "Song"); + assert_eq!(track.length, 180_000); + } + + #[test] + fn track_serde_roundtrip() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + let json = serde_json::to_string(&track).unwrap(); + let decoded: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(track, decoded); + } + + #[test] + fn uri_to_path_with_scheme() { + let path = uri_to_path("file:///home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn uri_to_path_plain_path() { + let path = uri_to_path("/home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn path_to_uri_conversion() { + let uri = path_to_uri(Path::new("/home/pi/Music/song.mp3")); + assert_eq!(uri, "file:///home/pi/Music/song.mp3"); + } + + #[test] + fn playback_state_equality() { + assert_eq!(PlaybackState::Stopped, PlaybackState::Stopped); + assert_ne!(PlaybackState::Playing, PlaybackState::Paused); + } +} +``` + +- [ ] **Step 3: Wire up modules in lib.rs** + +Replace `crates/rustify-core/src/lib.rs` with: + +```rust +pub mod error; +pub mod types; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-core/src/ +git commit -m "feat: add error and types modules with tests" +``` + +--- + +### Task 3: mixer.rs + +**Files:** +- Create: `crates/rustify-core/src/mixer.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write mixer.rs with tests** + +Create `crates/rustify-core/src/mixer.rs`: + +```rust +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Lock-free volume control using atomic operations. +/// Volume ranges from 0 (silent) to 100 (full). +pub struct Mixer { + volume: AtomicU8, +} + +impl Mixer { + /// Create a new mixer with the given initial volume (clamped to 0-100). + pub fn new(initial_volume: u8) -> Self { + Self { + volume: AtomicU8::new(initial_volume.min(100)), + } + } + + /// Set the volume (clamped to 0-100). + pub fn set_volume(&self, volume: u8) { + self.volume.store(volume.min(100), Ordering::Relaxed); + } + + /// Get the current volume (0-100). + pub fn get_volume(&self) -> u8 { + self.volume.load(Ordering::Relaxed) + } + + /// Get the gain multiplier (0.0 - 1.0) for applying to audio samples. + pub fn gain(&self) -> f32 { + self.get_volume() as f32 / 100.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_volume() { + let mixer = Mixer::new(75); + assert_eq!(mixer.get_volume(), 75); + } + + #[test] + fn clamps_initial_volume_to_100() { + let mixer = Mixer::new(150); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn set_and_get_volume() { + let mixer = Mixer::new(50); + mixer.set_volume(80); + assert_eq!(mixer.get_volume(), 80); + } + + #[test] + fn clamps_set_volume_to_100() { + let mixer = Mixer::new(50); + mixer.set_volume(200); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn volume_zero() { + let mixer = Mixer::new(0); + assert_eq!(mixer.get_volume(), 0); + assert_eq!(mixer.gain(), 0.0); + } + + #[test] + fn gain_at_full_volume() { + let mixer = Mixer::new(100); + assert!((mixer.gain() - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_half_volume() { + let mixer = Mixer::new(50); + assert!((mixer.gain() - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_zero_volume() { + let mixer = Mixer::new(0); + assert!((mixer.gain() - 0.0).abs() < f32::EPSILON); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add to `crates/rustify-core/src/lib.rs`: + +```rust +pub mod error; +pub mod mixer; +pub mod types; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass (error, types, and mixer). + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/mixer.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add lock-free mixer with atomic volume control" +``` + +--- + +### Task 4: tracklist.rs + +**Files:** +- Create: `crates/rustify-core/src/tracklist.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write tracklist.rs with tests** + +Create `crates/rustify-core/src/tracklist.rs`: + +```rust +use std::collections::VecDeque; + +/// A playback queue backed by VecDeque. +/// Stores track URIs and maintains a current position index. +pub struct Tracklist { + tracks: VecDeque, + current_index: Option, +} + +impl Tracklist { + pub fn new() -> Self { + Self { + tracks: VecDeque::new(), + current_index: None, + } + } + + /// Append a single track URI to the end of the queue. + pub fn add(&mut self, uri: String) { + self.tracks.push_back(uri); + } + + /// Replace the entire tracklist with the given URIs. + /// Sets the current position to the first track if non-empty. + pub fn load(&mut self, uris: Vec) { + self.tracks.clear(); + self.tracks.extend(uris); + self.current_index = if self.tracks.is_empty() { + None + } else { + Some(0) + }; + } + + /// Remove all tracks and reset position. + pub fn clear(&mut self) { + self.tracks.clear(); + self.current_index = None; + } + + /// Get the URI of the current track, if any. + pub fn current(&self) -> Option<&str> { + self.current_index + .and_then(|i| self.tracks.get(i)) + .map(String::as_str) + } + + /// Advance to the next track and return its URI. + /// Returns None if already at the end. + pub fn next(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx + 1 < self.tracks.len() { + self.current_index = Some(idx + 1); + self.current() + } else { + None + } + } + + /// Go back to the previous track and return its URI. + /// Returns None if already at the beginning. + pub fn previous(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx > 0 { + self.current_index = Some(idx - 1); + self.current() + } else { + None + } + } + + /// Get the current track index (0-based). + pub fn index(&self) -> Option { + self.current_index + } + + /// Get the total number of tracks. + pub fn len(&self) -> usize { + self.tracks.len() + } + + /// Check if the tracklist is empty. + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } +} + +impl Default for Tracklist { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_tracklist_is_empty() { + let tl = Tracklist::new(); + assert!(tl.is_empty()); + assert_eq!(tl.len(), 0); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn add_does_not_set_current() { + let mut tl = Tracklist::new(); + tl.add("file:///a.mp3".into()); + // add alone does not set current_index + assert_eq!(tl.len(), 1); + assert!(tl.current().is_none()); + } + + #[test] + fn load_sets_current_to_first() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + ]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.index(), Some(0)); + assert_eq!(tl.current(), Some("file:///a.mp3")); + } + + #[test] + fn load_empty_sets_none() { + let mut tl = Tracklist::new(); + tl.load(vec![]); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + } + + #[test] + fn load_replaces_existing() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.load(vec!["file:///b.mp3".into(), "file:///c.mp3".into()]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.current(), Some("file:///b.mp3")); + } + + #[test] + fn clear_resets_everything() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.clear(); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn next_advances() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + assert_eq!(tl.next(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.next(), Some("file:///c.mp3")); + assert_eq!(tl.index(), Some(2)); + } + + #[test] + fn next_at_end_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.next(), None); + // Index stays at 0 (doesn't advance past end) + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn next_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.next(), None); + } + + #[test] + fn previous_goes_back() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.next(); // -> b + tl.next(); // -> c + assert_eq!(tl.previous(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.previous(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_at_start_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.previous(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.previous(), None); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod tracklist;` to `crates/rustify-core/src/lib.rs` (keep alphabetical order). + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/tracklist.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add tracklist with VecDeque-backed playback queue" +``` + +--- + +### Task 5: playlist.rs + +**Files:** +- Create: `crates/rustify-core/src/playlist.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write playlist.rs with tests** + +Create `crates/rustify-core/src/playlist.rs`: + +```rust +use std::fs; +use std::path::Path; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, Playlist}; + +/// Supported audio file extensions for playlist entries. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Parse an M3U playlist file and return resolved file:// URIs. +/// +/// Handles simple M3U and extended M3U (`#EXTM3U` / `#EXTINF`). +/// Relative paths are resolved against the M3U file's parent directory. +/// Only entries with supported audio extensions are included. +pub fn parse_m3u(path: &Path) -> Result, RustifyError> { + let content = fs::read_to_string(path).map_err(|e| { + RustifyError::Playlist(format!("failed to read {}: {e}", path.display())) + })?; + + let base_dir = path + .parent() + .ok_or_else(|| RustifyError::Playlist("M3U path has no parent directory".into()))?; + + let mut uris = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let track_path = if Path::new(line).is_absolute() { + Path::new(line).to_path_buf() + } else { + base_dir.join(line) + }; + + if let Some(ext) = track_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(&track_path)); + } + } + } + + Ok(uris) +} + +/// Find all .m3u playlist files in a directory (non-recursive). +/// Returns metadata about each playlist including track count. +pub fn find_playlists(dir: &Path) -> Result, RustifyError> { + let mut playlists = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some("m3u") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let track_count = parse_m3u(&path).map(|uris| uris.len()).unwrap_or(0); + + playlists.push(Playlist { + uri: path_to_uri(&path), + name, + track_count, + }); + } + } + + Ok(playlists) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_m3u(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn touch(dir: &Path, name: &str) { + fs::write(dir.join(name), b"").unwrap(); + } + + #[test] + fn parse_simple_m3u_absolute_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song1.mp3\n/music/song2.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert_eq!(uris[0], "file:///music/song1.mp3"); + assert_eq!(uris[1], "file:///music/song2.flac"); + } + + #[test] + fn parse_m3u_relative_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "songs/track.mp3\n../other/track.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + // Should be resolved relative to M3U directory + assert!(uris[0].contains("songs")); + assert!(uris[1].contains("other")); + } + + #[test] + fn parse_extended_m3u_skips_directives() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "#EXTM3U\n#EXTINF:123,Artist - Title\n/music/song.mp3\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert_eq!(uris[0], "file:///music/song.mp3"); + } + + #[test] + fn parse_m3u_skips_blank_lines_and_comments() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "\n# comment\n\n/music/song.mp3\n\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + } + + #[test] + fn parse_m3u_filters_unsupported_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.mp3\n/music/image.png\n/music/doc.txt\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn parse_m3u_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "test.m3u", "/music/song.MP3\n/music/song.Flac\n"); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn parse_m3u_nonexistent_file_returns_error() { + let result = parse_m3u(Path::new("/nonexistent/playlist.m3u")); + assert!(result.is_err()); + } + + #[test] + fn parse_empty_m3u() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "empty.m3u", ""); + let uris = parse_m3u(&m3u).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn find_playlists_in_directory() { + let dir = TempDir::new().unwrap(); + // Create M3U files with referenced tracks + create_m3u(dir.path(), "chill.m3u", "/music/a.mp3\n/music/b.flac\n"); + create_m3u(dir.path(), "rock.m3u", "/music/c.ogg\n"); + // Create a non-M3U file (should be ignored) + touch(dir.path(), "readme.txt"); + + let playlists = find_playlists(dir.path()).unwrap(); + assert_eq!(playlists.len(), 2); + + let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"chill")); + assert!(names.contains(&"rock")); + + let chill = playlists.iter().find(|p| p.name == "chill").unwrap(); + assert_eq!(chill.track_count, 2); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod playlist;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/playlist.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add M3U playlist parser with path resolution" +``` + +--- + +### Task 6: scanner.rs + +**Files:** +- Create: `crates/rustify-core/src/scanner.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write scanner.rs with tests** + +Create `crates/rustify-core/src/scanner.rs`: + +```rust +use std::path::Path; + +use walkdir::WalkDir; + +use crate::error::RustifyError; +use crate::types::path_to_uri; + +/// Supported audio file extensions. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Recursively scan a directory for audio files. +/// Returns sorted `file://` URIs for all files matching supported extensions. +pub fn scan_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut uris = Vec::new(); + + for entry in WalkDir::new(path).follow_links(true) { + let entry = entry.map_err(|e| { + RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + })?; + + if !entry.file_type().is_file() { + continue; + } + + if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(entry.path())); + } + } + } + + uris.sort(); + Ok(uris) +} + +/// List the contents of a single directory (non-recursive). +/// Returns URIs for audio files and subdirectories. +pub fn browse_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut entries = Vec::new(); + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + entries.push(path_to_uri(&entry_path)); + } else if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + entries.push(path_to_uri(&entry_path)); + } + } + } + + entries.sort(); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn touch(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); + } + + #[test] + fn scan_finds_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("track.flac")); + touch(&dir.path().join("sound.ogg")); + touch(&dir.path().join("clip.wav")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 4); + } + + #[test] + fn scan_ignores_non_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("readme.txt")); + touch(&dir.path().join("image.png")); + touch(&dir.path().join("cover.jpg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn scan_recurses_into_subdirectories() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("artist1/album1/track1.mp3")); + touch(&dir.path().join("artist1/album2/track2.flac")); + touch(&dir.path().join("artist2/track3.ogg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 3); + } + + #[test] + fn scan_returns_sorted_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("c.mp3")); + touch(&dir.path().join("a.mp3")); + touch(&dir.path().join("b.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0] < uris[1]); + assert!(uris[1] < uris[2]); + } + + #[test] + fn scan_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("LOUD.MP3")); + touch(&dir.path().join("quiet.Flac")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn scan_returns_file_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0].starts_with("file://")); + } + + #[test] + fn scan_nonexistent_directory_returns_error() { + let result = scan_directory(Path::new("/nonexistent/path")); + assert!(result.is_err()); + } + + #[test] + fn scan_empty_directory() { + let dir = TempDir::new().unwrap(); + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn browse_lists_files_and_dirs() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + fs::create_dir(dir.path().join("subdir")).unwrap(); + touch(&dir.path().join("readme.txt")); + + let entries = browse_directory(dir.path()).unwrap(); + // Should include song.mp3 and subdir, but not readme.txt + assert_eq!(entries.len(), 2); + } + + #[test] + fn browse_does_not_recurse() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("top.mp3")); + touch(&dir.path().join("sub/nested.mp3")); + + let entries = browse_directory(dir.path()).unwrap(); + // Should only include top.mp3 and sub/ directory + assert_eq!(entries.len(), 2); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod scanner;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/scanner.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add recursive audio file scanner with browse support" +``` + +--- + +### Task 7: metadata.rs + +**Files:** +- Create: `crates/rustify-core/src/metadata.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write metadata.rs with tests** + +Create `crates/rustify-core/src/metadata.rs`: + +```rust +use std::path::Path; + +use lofty::prelude::*; +use lofty::probe::Probe; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, uri_to_path, Track}; + +/// Read audio metadata from a file URI or plain path. +/// Falls back to filename-derived metadata if tags are missing. +pub fn read_metadata(uri: &str) -> Result { + let path = uri_to_path(uri); + read_metadata_from_path(&path) +} + +/// Read audio metadata from a filesystem path. +pub fn read_metadata_from_path(path: &Path) -> Result { + let tagged_file = Probe::open(path) + .map_err(|e| RustifyError::Metadata(format!("failed to open {}: {e}", path.display())))? + .read() + .map_err(|e| { + RustifyError::Metadata(format!("failed to read tags from {}: {e}", path.display())) + })?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let name = tag + .and_then(|t| t.title().map(|s| s.to_string())) + .unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + + let artists = tag + .and_then(|t| t.artist().map(|s| vec![s.to_string()])) + .unwrap_or_default(); + + let album = tag + .and_then(|t| t.album().map(|s| s.to_string())) + .unwrap_or_default(); + + let track_no = tag.and_then(|t| t.track()); + + let length = tagged_file.properties().duration().as_millis() as u64; + + Ok(Track { + uri: path_to_uri(path), + name, + artists, + album, + length, + track_no, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a minimal valid WAV file (44-byte header + 1 second of silence). + fn create_test_wav() -> NamedTempFile { + let mut file = NamedTempFile::with_suffix(".wav").unwrap(); + let sample_rate: u32 = 44100; + let channels: u16 = 1; + let bits_per_sample: u16 = 16; + let num_samples: u32 = sample_rate; // 1 second + let data_size: u32 = num_samples * (bits_per_sample / 8) as u32 * channels as u32; + let file_size: u32 = 36 + data_size; + + // RIFF header + file.write_all(b"RIFF").unwrap(); + file.write_all(&file_size.to_le_bytes()).unwrap(); + file.write_all(b"WAVE").unwrap(); + // fmt chunk + file.write_all(b"fmt ").unwrap(); + file.write_all(&16u32.to_le_bytes()).unwrap(); // chunk size + file.write_all(&1u16.to_le_bytes()).unwrap(); // PCM format + file.write_all(&channels.to_le_bytes()).unwrap(); + file.write_all(&sample_rate.to_le_bytes()).unwrap(); + let byte_rate = sample_rate * channels as u32 * (bits_per_sample / 8) as u32; + file.write_all(&byte_rate.to_le_bytes()).unwrap(); + let block_align = channels * (bits_per_sample / 8); + file.write_all(&block_align.to_le_bytes()).unwrap(); + file.write_all(&bits_per_sample.to_le_bytes()).unwrap(); + // data chunk + file.write_all(b"data").unwrap(); + file.write_all(&data_size.to_le_bytes()).unwrap(); + // Write silence + let silence = vec![0u8; data_size as usize]; + file.write_all(&silence).unwrap(); + file.flush().unwrap(); + file + } + + #[test] + fn read_metadata_from_wav_falls_back_to_filename() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + + // WAV files without tags should fall back to filename + let expected_name = wav + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!(track.name, expected_name); + assert!(track.artists.is_empty()); + assert!(track.album.is_empty()); + } + + #[test] + fn read_metadata_returns_file_uri() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.uri.starts_with("file://")); + } + + #[test] + fn read_metadata_reports_duration() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + // 1 second of audio at 44100Hz should be ~1000ms + assert!(track.length > 900 && track.length < 1100); + } + + #[test] + fn read_metadata_via_uri() { + let wav = create_test_wav(); + let uri = path_to_uri(wav.path()); + let track = read_metadata(&uri).unwrap(); + assert!(track.length > 0); + } + + #[test] + fn read_metadata_nonexistent_file_returns_error() { + let result = read_metadata("file:///nonexistent/song.mp3"); + assert!(result.is_err()); + } +} +``` + +Note: The lofty import paths may differ between versions. If `lofty::prelude::*` doesn't exist, use `lofty::file::TaggedFileExt` and `lofty::tag::Accessor` directly. If `Probe::open` doesn't exist, try `lofty::read_from_path(path)`. + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod metadata;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. If lofty API differs, adjust the imports. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/metadata.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add metadata reader with lofty and filename fallback" +``` + +--- + +### Task 8: Wire up lib.rs with re-exports + +**Files:** +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Update lib.rs with all modules and convenience re-exports** + +Replace `crates/rustify-core/src/lib.rs` with: + +```rust +pub mod error; +pub mod metadata; +pub mod mixer; +pub mod playlist; +pub mod scanner; +pub mod tracklist; +pub mod types; + +// Re-export primary types at crate root for convenience. +pub use error::{Result, RustifyError}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; +``` + +- [ ] **Step 2: Run full test suite** + +Run: `cargo test -p rustify-core` +Expected: All tests pass (error, types, mixer, tracklist, playlist, scanner, metadata). + +- [ ] **Step 3: Run clippy** + +Run: `cargo clippy -p rustify-core -- -D warnings` +Expected: No warnings. Fix any issues before committing. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/lib.rs +git commit -m "feat: wire up all modules in lib.rs with re-exports" +``` + +--- + +### Task 9: player.rs — Playback Engine + +This is the largest task. It implements the three-thread architecture: command loop, decode thread, and cpal output. + +**Files:** +- Create: `crates/rustify-core/src/player.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write the player module with internal types** + +Create `crates/rustify-core/src/player.rs`: + +```rust +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use crossbeam::channel::{self, Receiver, Sender, TryRecvError}; + +use crate::error::RustifyError; +use crate::metadata::read_metadata_from_path; +use crate::mixer::Mixer; +use crate::tracklist::Tracklist; +use crate::types::{uri_to_path, PlaybackState, PlayerCommand, PlayerEvent, Track}; + +/// Number of audio chunks buffered between decode and output threads. +/// At ~1024 frames per chunk @ 44.1kHz stereo, each chunk is ~23ms. +/// 100 chunks provides ~2.3 seconds of buffer. +const BUFFER_CHUNKS: usize = 100; + +// --- Public API --- + +/// Configuration for creating a Player. +pub struct PlayerConfig { + pub alsa_device: String, + pub music_dirs: Vec, +} + +/// The main player handle. All methods are non-blocking — they send commands +/// to the internal command thread via a crossbeam channel. +pub struct Player { + cmd_tx: Sender, + shared: Arc, + mixer: Arc, + music_dirs: Vec, + _command_thread: Option>, +} + +impl Player { + /// Create a new player. Spawns the command thread immediately. + /// The output stream is created lazily on first `play()`. + pub fn new(config: PlayerConfig) -> Result { + let (cmd_tx, cmd_rx) = channel::unbounded::(); + let shared = Arc::new(SharedState::new()); + let mixer = Arc::new(Mixer::new(100)); + + let shared_clone = Arc::clone(&shared); + let mixer_clone = Arc::clone(&mixer); + let alsa_device = config.alsa_device.clone(); + + let handle = thread::Builder::new() + .name("rustify-cmd".into()) + .spawn(move || { + let mut cmd_loop = CommandLoop::new( + cmd_rx, + shared_clone, + mixer_clone, + alsa_device, + ); + cmd_loop.run(); + }) + .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; + + Ok(Self { + cmd_tx, + shared, + mixer, + music_dirs: config.music_dirs, + _command_thread: Some(handle), + }) + } + + // --- Transport commands (non-blocking, fire-and-forget) --- + + pub fn play(&self) { + self.cmd_tx.send(PlayerCommand::Play).ok(); + } + + pub fn pause(&self) { + self.cmd_tx.send(PlayerCommand::Pause).ok(); + } + + pub fn stop(&self) { + self.cmd_tx.send(PlayerCommand::Stop).ok(); + } + + pub fn next(&self) { + self.cmd_tx.send(PlayerCommand::Next).ok(); + } + + pub fn previous(&self) { + self.cmd_tx.send(PlayerCommand::Previous).ok(); + } + + pub fn seek(&self, position_ms: u64) { + self.cmd_tx.send(PlayerCommand::Seek(position_ms)).ok(); + } + + pub fn set_volume(&self, volume: u8) { + self.mixer.set_volume(volume); + } + + pub fn get_volume(&self) -> u8 { + self.mixer.get_volume() + } + + pub fn load_track_uris(&self, uris: Vec) { + self.cmd_tx.send(PlayerCommand::LoadTrackUris(uris)).ok(); + } + + pub fn clear_tracklist(&self) { + self.cmd_tx.send(PlayerCommand::ClearTracklist).ok(); + } + + pub fn shutdown(&self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } + + // --- State queries (read from shared atomic/mutex state) --- + + pub fn get_playback_state(&self) -> PlaybackState { + self.shared.get_playback_state() + } + + pub fn get_current_track(&self) -> Option { + self.shared.current_track.lock().unwrap().clone() + } + + pub fn get_time_position(&self) -> u64 { + self.shared.time_position_ms.load(Ordering::Relaxed) + } + + // --- Callback registration --- + + pub fn on_state_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_state_change + .push(callback); + } + + pub fn on_track_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_track_change + .push(callback); + } + + pub fn on_position_update(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_position_update + .push(callback); + } + + pub fn on_error(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_error + .push(callback); + } +} + +// --- Shared State --- + +struct SharedState { + /// Encoded PlaybackState: 0=Stopped, 1=Playing, 2=Paused + playback_state: AtomicU8, + current_track: Mutex>, + time_position_ms: AtomicU64, + callbacks: Mutex, +} + +impl SharedState { + fn new() -> Self { + Self { + playback_state: AtomicU8::new(0), + current_track: Mutex::new(None), + time_position_ms: AtomicU64::new(0), + callbacks: Mutex::new(Callbacks::default()), + } + } + + fn get_playback_state(&self) -> PlaybackState { + match self.playback_state.load(Ordering::Relaxed) { + 1 => PlaybackState::Playing, + 2 => PlaybackState::Paused, + _ => PlaybackState::Stopped, + } + } + + fn set_playback_state(&self, state: PlaybackState) { + let val = match state { + PlaybackState::Stopped => 0, + PlaybackState::Playing => 1, + PlaybackState::Paused => 2, + }; + self.playback_state.store(val, Ordering::Relaxed); + } +} + +#[derive(Default)] +struct Callbacks { + on_state_change: Vec>, + on_track_change: Vec>, + on_position_update: Vec>, + on_error: Vec>, +} + +// --- Internal Events (decode thread -> command loop) --- + +enum InternalEvent { + TrackChanged(Track), + Position(u64), + TrackEnded, + Error(String), +} + +/// Control messages from command loop to decode thread. +enum DecodeControl { + Pause, + Resume, + Seek(u64), + Stop, +} + +struct DecodeHandle { + control_tx: Sender, + _thread: JoinHandle<()>, +} + +// --- Command Loop (runs on dedicated thread) --- + +struct CommandLoop { + cmd_rx: Receiver, + event_rx: Receiver, + event_tx: Sender, + shared: Arc, + mixer: Arc, + tracklist: Tracklist, + decode_handle: Option, + audio_tx: Sender>, + _audio_stream: Option, + clear_buffer: Arc, + alsa_device: String, +} + +impl CommandLoop { + fn new( + cmd_rx: Receiver, + shared: Arc, + mixer: Arc, + alsa_device: String, + ) -> Self { + let (event_tx, event_rx) = channel::unbounded::(); + let (audio_tx, audio_rx) = channel::bounded::>(BUFFER_CHUNKS); + let clear_buffer = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + // Create the cpal output stream + let stream = + create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + + Self { + cmd_rx, + event_rx, + event_tx, + shared, + mixer, + tracklist: Tracklist::new(), + decode_handle: None, + audio_tx, + _audio_stream: stream.ok(), + clear_buffer, + alsa_device, + } + } + + fn run(&mut self) { + loop { + crossbeam::select! { + recv(self.cmd_rx) -> cmd => { + match cmd { + Ok(PlayerCommand::Shutdown) => { + self.stop_decode(); + break; + } + Ok(cmd) => self.handle_command(cmd), + Err(_) => break, // Sender dropped + } + } + recv(self.event_rx) -> event => { + match event { + Ok(evt) => self.handle_event(evt), + Err(_) => {} // No decode thread running + } + } + } + } + } + + fn handle_command(&mut self, cmd: PlayerCommand) { + match cmd { + PlayerCommand::Play => self.handle_play(), + PlayerCommand::Pause => self.handle_pause(), + PlayerCommand::Stop => self.handle_stop(), + PlayerCommand::Next => self.handle_next(), + PlayerCommand::Previous => self.handle_previous(), + PlayerCommand::Seek(ms) => self.handle_seek(ms), + PlayerCommand::SetVolume(vol) => self.mixer.set_volume(vol), + PlayerCommand::LoadTrackUris(uris) => { + self.handle_stop(); + self.tracklist.load(uris); + } + PlayerCommand::ClearTracklist => { + self.handle_stop(); + self.tracklist.clear(); + } + PlayerCommand::Shutdown => unreachable!(), + } + } + + fn handle_event(&mut self, event: InternalEvent) { + match event { + InternalEvent::TrackChanged(track) => { + *self.shared.current_track.lock().unwrap() = Some(track.clone()); + self.emit_callbacks(PlayerEvent::TrackChanged(track)); + } + InternalEvent::Position(ms) => { + self.shared.time_position_ms.store(ms, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::PositionUpdate(ms)); + } + InternalEvent::TrackEnded => { + // Try to advance to next track + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + // End of tracklist + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } + InternalEvent::Error(msg) => { + self.emit_callbacks(PlayerEvent::Error(msg)); + } + } + } + + fn handle_play(&mut self) { + match self.shared.get_playback_state() { + PlaybackState::Stopped => { + if let Some(uri) = self.tracklist.current() { + let uri = uri.to_string(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + PlaybackState::Paused => { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Resume).ok(); + } + self.set_state(PlaybackState::Playing); + } + PlaybackState::Playing => {} // Already playing + } + } + + fn handle_pause(&mut self) { + if self.shared.get_playback_state() == PlaybackState::Playing { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Pause).ok(); + } + self.set_state(PlaybackState::Paused); + } + } + + fn handle_stop(&mut self) { + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + + fn handle_next(&mut self) { + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } else { + self.handle_stop(); + } + } + + fn handle_previous(&mut self) { + if let Some(uri) = self.tracklist.previous() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + + fn handle_seek(&mut self, ms: u64) { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Seek(ms)).ok(); + } + } + + fn start_decode(&mut self, uri: String) { + // Clear any stale audio in the buffer + self.clear_buffer + .store(true, Ordering::Relaxed); + + let (control_tx, control_rx) = channel::unbounded::(); + let audio_tx = self.audio_tx.clone(); + let event_tx = self.event_tx.clone(); + + let handle = thread::Builder::new() + .name("rustify-decode".into()) + .spawn(move || { + decode_thread(uri, audio_tx, control_rx, event_tx); + }) + .expect("failed to spawn decode thread"); + + self.decode_handle = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + } + + fn stop_decode(&mut self) { + if let Some(handle) = self.decode_handle.take() { + handle.control_tx.send(DecodeControl::Stop).ok(); + // Don't join — the thread will exit when it sees Stop or channel disconnect + } + self.clear_buffer + .store(true, Ordering::Relaxed); + } + + fn set_state(&mut self, state: PlaybackState) { + self.shared.set_playback_state(state); + self.emit_callbacks(PlayerEvent::StateChanged(state)); + } + + fn emit_callbacks(&self, event: PlayerEvent) { + let callbacks = self.shared.callbacks.lock().unwrap(); + match &event { + PlayerEvent::StateChanged(state) => { + for cb in &callbacks.on_state_change { + cb(*state); + } + } + PlayerEvent::TrackChanged(track) => { + for cb in &callbacks.on_track_change { + cb(track.clone()); + } + } + PlayerEvent::PositionUpdate(ms) => { + for cb in &callbacks.on_position_update { + cb(*ms); + } + } + PlayerEvent::Error(msg) => { + for cb in &callbacks.on_error { + cb(msg.clone()); + } + } + } + } +} + +// --- Decode Thread --- + +fn decode_thread( + uri: String, + audio_tx: Sender>, + control_rx: Receiver, + event_tx: Sender, +) { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::DecoderOptions; + use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let path = uri_to_path(&uri); + + // Read metadata for TrackChanged event + match read_metadata_from_path(&path) { + Ok(track) => { + event_tx.send(InternalEvent::TrackChanged(track)).ok(); + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("metadata: {e}"))) + .ok(); + } + } + + // Open file with symphonia + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("open: {e}"))) + .ok(); + return; + } + }; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = match symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) { + Ok(p) => p, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("probe: {e}"))) + .ok(); + return; + } + }; + + let mut format = probed.format; + let track = match format.default_track() { + Some(t) => t, + None => { + event_tx + .send(InternalEvent::Error("no audio track found".into())) + .ok(); + return; + } + }; + let track_id = track.id; + let time_base = track.codec_params.time_base; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut decoder = match symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + { + Ok(d) => d, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("decoder: {e}"))) + .ok(); + return; + } + }; + + let mut paused = false; + let mut sample_buf: Option> = None; + let mut last_position_report_ms: u64 = 0; + + loop { + // Check for control messages (non-blocking when not paused) + if paused { + // Block until we get a control message + match control_rx.recv() { + Ok(DecodeControl::Resume) => { + paused = false; + continue; + } + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Ok(DecodeControl::Pause) => continue, + Err(_) => break, + } + } else { + match control_rx.try_recv() { + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Pause) => { + paused = true; + continue; + } + Ok(DecodeControl::Resume) => {} + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => break, + } + } + + // Decode next packet + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + event_tx.send(InternalEvent::TrackEnded).ok(); + break; + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("packet: {e}"))) + .ok(); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + // Compute position in milliseconds + let position_ms = if let Some(tb) = time_base { + (packet.ts() as f64 * tb.numer as f64 / tb.denom as f64 * 1000.0) as u64 + } else { + (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64 + }; + + // Report position every ~1 second + if position_ms >= last_position_report_ms + 1000 || position_ms < last_position_report_ms { + event_tx.send(InternalEvent::Position(position_ms)).ok(); + last_position_report_ms = position_ms; + } + + // Decode the packet + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + // Skip corrupt frames + event_tx + .send(InternalEvent::Error(format!("frame: {e}"))) + .ok(); + continue; + } + }; + + // Convert to interleaved f32 and send to output + let sbuf = sample_buf.get_or_insert_with(|| { + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()) + }); + + sbuf.copy_interleaved_ref(decoded); + let chunk = sbuf.samples().to_vec(); + + if audio_tx.send(chunk).is_err() { + break; // Output stream dropped + } + } +} + +fn seek_to( + format: &mut Box, + track_id: u32, + ms: u64, + time_base: Option, + sample_rate: u32, + event_tx: &Sender, +) { + let seek_ts = if let Some(tb) = time_base { + (ms as f64 / 1000.0 * tb.denom as f64 / tb.numer as f64) as u64 + } else { + (ms as f64 / 1000.0 * sample_rate as f64) as u64 + }; + + if let Err(e) = format.seek(SeekMode::Coarse, SeekTo::TimeStamp { ts: seek_ts, track_id }) { + event_tx + .send(InternalEvent::Error(format!("seek: {e}"))) + .ok(); + } +} + +// --- Output Stream (cpal) --- + +fn create_output_stream( + audio_rx: Receiver>, + mixer: Arc, + clear_buffer: Arc, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RustifyError::Audio("no default output device".into()))?; + + let config = device + .default_output_config() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + let config: cpal::StreamConfig = config.into(); + + let mut buf: VecDeque = VecDeque::with_capacity(8192); + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Check if we should clear stale audio + if clear_buffer.swap(false, Ordering::Relaxed) { + buf.clear(); + while audio_rx.try_recv().is_ok() {} + } + + let gain = mixer.gain(); + for sample in data.iter_mut() { + if buf.is_empty() { + match audio_rx.try_recv() { + Ok(chunk) => buf.extend(chunk), + Err(_) => { + *sample = 0.0; + continue; + } + } + } + *sample = buf.pop_front().unwrap_or(0.0) * gain; + } + }, + |err| { + eprintln!("cpal stream error: {err}"); + }, + None, + ) + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + stream + .play() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use std::time::Duration; + + #[test] + fn shared_state_initial_values() { + let state = SharedState::new(); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + assert!(state.current_track.lock().unwrap().is_none()); + assert_eq!(state.time_position_ms.load(Ordering::Relaxed), 0); + } + + #[test] + fn shared_state_playback_transitions() { + let state = SharedState::new(); + state.set_playback_state(PlaybackState::Playing); + assert_eq!(state.get_playback_state(), PlaybackState::Playing); + + state.set_playback_state(PlaybackState::Paused); + assert_eq!(state.get_playback_state(), PlaybackState::Paused); + + state.set_playback_state(PlaybackState::Stopped); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + } + + #[test] + fn player_new_starts_in_stopped_state() { + // This test only works if an audio device is available. + // On CI without audio, it will fail at stream creation but the + // command thread will still start with Stopped state. + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + let player = Player::new(config); + // Player creation may fail without audio device — that's expected on CI. + if let Ok(player) = player { + assert_eq!(player.get_playback_state(), PlaybackState::Stopped); + assert!(player.get_current_track().is_none()); + assert_eq!(player.get_time_position(), 0); + player.shutdown(); + } + } + + #[test] + fn player_volume_control_bypasses_command_thread() { + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + if let Ok(player) = Player::new(config) { + player.set_volume(75); + assert_eq!(player.get_volume(), 75); + player.shutdown(); + } + } +} +``` + +- [ ] **Step 2: Add player module to lib.rs** + +Add to `crates/rustify-core/src/lib.rs`: + +```rust +pub mod error; +pub mod metadata; +pub mod mixer; +pub mod player; +pub mod playlist; +pub mod scanner; +pub mod tracklist; +pub mod types; + +pub use error::{Result, RustifyError}; +pub use player::{Player, PlayerConfig}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: Foundation module tests pass. Player tests may skip on CI if no audio device. + +Run: `cargo clippy -p rustify-core -- -D warnings` +Fix any issues. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/player.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add three-thread playback engine with symphonia + cpal" +``` + +--- + +### Task 10: Python Bindings + +**Files:** +- Modify: `bindings/python/src/lib.rs` +- Create: `bindings/python/src/client.rs` +- Create: `bindings/python/rustify/__init__.py` +- Create: `bindings/python/rustify/py.typed` + +- [ ] **Step 1: Write the Python-facing types and module entry point** + +Replace `bindings/python/src/lib.rs`: + +```rust +mod client; + +use pyo3::prelude::*; + +use client::{PyPlaylist, PyTrack, RustifyClient}; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 2: Write the RustifyClient pyclass** + +Create `bindings/python/src/client.rs`: + +```rust +use std::path::{Path, PathBuf}; + +use pyo3::prelude::*; +use pyo3::types::PyString; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::PlaybackState; +use rustify_core::{metadata, playlist, scanner, types}; + +// --- Python-facing data types --- + +#[pyclass(name = "Track")] +#[derive(Clone)] +pub struct PyTrack { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub artists: Vec, + #[pyo3(get)] + pub album: String, + #[pyo3(get)] + pub length: u64, + #[pyo3(get)] + pub track_no: Option, +} + +#[pymethods] +impl PyTrack { + fn __repr__(&self) -> String { + format!("Track(name={:?}, artists={:?})", self.name, self.artists) + } +} + +impl From for PyTrack { + fn from(t: types::Track) -> Self { + Self { + uri: t.uri, + name: t.name, + artists: t.artists, + album: t.album, + length: t.length, + track_no: t.track_no, + } + } +} + +#[pyclass(name = "Playlist")] +#[derive(Clone)] +pub struct PyPlaylist { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub track_count: usize, +} + +#[pymethods] +impl PyPlaylist { + fn __repr__(&self) -> String { + format!( + "Playlist(name={:?}, track_count={})", + self.name, self.track_count + ) + } +} + +impl From for PyPlaylist { + fn from(p: types::Playlist) -> Self { + Self { + uri: p.uri, + name: p.name, + track_count: p.track_count, + } + } +} + +// --- RustifyClient --- + +#[pyclass] +pub struct RustifyClient { + player: Player, + music_dirs: Vec, +} + +#[pymethods] +impl RustifyClient { + #[new] + #[pyo3(signature = (alsa_device = "default".to_string(), music_dirs = vec![]))] + fn new(alsa_device: String, music_dirs: Vec) -> PyResult { + let dirs: Vec = music_dirs.iter().map(PathBuf::from).collect(); + let player = Player::new(PlayerConfig { + alsa_device, + music_dirs: dirs.clone(), + }) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + Ok(Self { + player, + music_dirs: dirs, + }) + } + + // --- Transport --- + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn next_track(&self) { + self.player.next(); + } + + fn previous_track(&self) { + self.player.previous(); + } + + fn seek(&self, position_ms: u64) { + self.player.seek(position_ms); + } + + // --- Volume --- + + fn set_volume(&self, volume: u8) { + self.player.set_volume(volume); + } + + fn get_volume(&self) -> u8 { + self.player.get_volume() + } + + // --- State queries --- + + fn get_playback_state(&self) -> &'static str { + match self.player.get_playback_state() { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + } + } + + fn get_current_track(&self) -> Option { + self.player.get_current_track().map(PyTrack::from) + } + + fn get_time_position(&self) -> u64 { + self.player.get_time_position() + } + + // --- Tracklist --- + + fn load_track_uris(&self, uris: Vec) { + self.player.load_track_uris(uris); + } + + fn clear_tracklist(&self) { + self.player.clear_tracklist(); + } + + // --- Library --- + + fn browse_library(&self, path: String) -> PyResult> { + let p = types::uri_to_path(&path); + scanner::browse_directory(&p) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + fn scan_library(&self) -> PyResult> { + let mut all_uris = Vec::new(); + for dir in &self.music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => all_uris.extend(uris), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + all_uris.sort(); + Ok(all_uris) + } + + // --- Playlists --- + + fn get_playlists(&self) -> PyResult> { + let mut all_playlists = Vec::new(); + for dir in &self.music_dirs { + match playlist::find_playlists(dir) { + Ok(pls) => all_playlists.extend(pls.into_iter().map(PyPlaylist::from)), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + Ok(all_playlists) + } + + fn load_playlist(&self, path: String) -> PyResult<()> { + let uris = playlist::parse_m3u(Path::new(&path)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + self.player.load_track_uris(uris); + Ok(()) + } + + // --- Metadata --- + + fn read_metadata(&self, uri: String) -> PyResult { + metadata::read_metadata(&uri) + .map(PyTrack::from) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + // --- Callbacks --- + + fn on_track_change(&self, callback: PyObject) { + self.player + .on_track_change(Box::new(move |track: types::Track| { + Python::with_gil(|py| { + let py_track = PyTrack::from(track); + if let Err(e) = callback.call1(py, (py_track,)) { + eprintln!("Python on_track_change callback error: {e}"); + } + }); + })); + } + + fn on_state_change(&self, callback: PyObject) { + self.player + .on_state_change(Box::new(move |state: PlaybackState| { + Python::with_gil(|py| { + let state_str = match state { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + }; + if let Err(e) = callback.call1(py, (state_str,)) { + eprintln!("Python on_state_change callback error: {e}"); + } + }); + })); + } + + fn on_position_update(&self, callback: PyObject) { + self.player + .on_position_update(Box::new(move |ms: u64| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (ms,)) { + eprintln!("Python on_position_update callback error: {e}"); + } + }); + })); + } + + fn on_error(&self, callback: PyObject) { + self.player + .on_error(Box::new(move |msg: String| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (msg,)) { + eprintln!("Python on_error callback error: {e}"); + } + }); + })); + } + + // --- Lifecycle --- + + fn shutdown(&self) { + self.player.shutdown(); + } +} +``` + +- [ ] **Step 3: Create Python package files** + +Create `bindings/python/rustify/__init__.py`: + +```python +"""Rustify — Embedded Rust media player for YoyoPod.""" + +from rustify._rustify import RustifyClient, Track, Playlist + +__all__ = ["RustifyClient", "Track", "Playlist"] +``` + +Create `bindings/python/rustify/py.typed` (empty file — PEP 561 marker): + +``` +``` + +- [ ] **Step 4: Verify Rust compilation** + +Run: `cargo check --workspace` +Expected: Both crates compile. + +- [ ] **Step 5: Commit** + +```bash +git add bindings/ pyproject.toml +git commit -m "feat: add PyO3 Python bindings with RustifyClient" +``` + +--- + +### Task 11: CLI Example + +**Files:** +- Create: `examples/play.rs` + +- [ ] **Step 1: Write the CLI player example** + +Create `examples/play.rs`: + +```rust +use std::env; +use std::io::{self, BufRead}; +use std::path::Path; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::path_to_uri; +use rustify_core::{playlist, scanner}; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: play [--playlist] [--scan]"); + eprintln!(" play song.mp3 Play a single file"); + eprintln!(" play --scan /Music Scan directory, play all"); + eprintln!(" play --playlist mix.m3u Load M3U playlist"); + std::process::exit(1); + } + + let config = PlayerConfig { + alsa_device: "default".to_string(), + music_dirs: vec![], + }; + + let player = Player::new(config).expect("Failed to create player"); + + // Register display callbacks + player.on_state_change(Box::new(|state| { + println!("[State] {state:?}"); + })); + player.on_track_change(Box::new(|track| { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + println!("[Track] {artist} — {}", track.name); + })); + player.on_position_update(Box::new(|ms| { + let secs = ms / 1000; + let mins = secs / 60; + print!("\r[{:02}:{:02}]", mins, secs % 60); + })); + player.on_error(Box::new(|msg| { + eprintln!("[Error] {msg}"); + })); + + // Parse args and load tracks + let is_scan = args.contains(&"--scan".to_string()); + let is_playlist = args.contains(&"--playlist".to_string()); + let path_arg = args + .iter() + .find(|a| !a.starts_with('-') && *a != &args[0]) + .expect("No path provided"); + + if is_scan { + let uris = scanner::scan_directory(Path::new(path_arg)).expect("Scan failed"); + println!("Found {} tracks", uris.len()); + player.load_track_uris(uris); + } else if is_playlist { + let uris = playlist::parse_m3u(Path::new(path_arg)).expect("Playlist parse failed"); + println!("Loaded {} tracks from playlist", uris.len()); + player.load_track_uris(uris); + } else { + player.load_track_uris(vec![path_to_uri(Path::new(path_arg))]); + } + + player.play(); + + println!("Commands: play, pause, stop, next, prev, vol <0-100>, quit"); + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + match line.trim() { + "play" | "p" => player.play(), + "pause" => player.pause(), + "stop" | "s" => player.stop(), + "next" | "n" => player.next(), + "prev" => player.previous(), + "quit" | "q" => { + player.shutdown(); + break; + } + cmd if cmd.starts_with("vol ") => { + if let Ok(vol) = cmd[4..].parse::() { + player.set_volume(vol); + println!("Volume: {vol}"); + } + } + "" => {} + other => println!("Unknown command: {other}"), + } + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p rustify-core --example play` +Expected: Compiles (won't run without audio device + actual files). + +- [ ] **Step 3: Commit** + +```bash +git add examples/play.rs +git commit -m "feat: add CLI player example for hardware testing" +``` + +--- + +### Task 12: Final Integration Verification + +- [ ] **Step 1: Run full Rust test suite** + +Run: `cargo test -p rustify-core` +Expected: All unit tests pass. Player tests may be skipped on headless CI. + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --workspace -- -D warnings` +Expected: No warnings. Fix any issues. + +- [ ] **Step 3: Check formatting** + +Run: `cargo fmt --check` +Expected: All files formatted. If not, run `cargo fmt` to fix. + +- [ ] **Step 4: Verify workspace check** + +Run: `cargo check --workspace` +Expected: Both rustify-core and rustify-python crates compile. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "chore: fix clippy warnings and formatting" +``` diff --git a/examples/play.rs b/examples/play.rs new file mode 100644 index 0000000..63b82dc --- /dev/null +++ b/examples/play.rs @@ -0,0 +1,97 @@ +use std::env; +use std::io::{self, BufRead}; +use std::path::Path; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::path_to_uri; +use rustify_core::{playlist, scanner}; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: play [--playlist] [--scan]"); + eprintln!(" play song.mp3 Play a single file"); + eprintln!(" play --scan /Music Scan directory, play all"); + eprintln!(" play --playlist mix.m3u Load M3U playlist"); + std::process::exit(1); + } + + let config = PlayerConfig { + alsa_device: "default".to_string(), + music_dirs: vec![], + }; + + let player = Player::new(config).expect("Failed to create player"); + + // Register display callbacks + player.on_state_change(Box::new(|state| { + println!("[State] {state:?}"); + })); + player.on_track_change(Box::new(|track| { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + println!("[Track] {artist} — {}", track.name); + })); + player.on_position_update(Box::new(|ms| { + let secs = ms / 1000; + let mins = secs / 60; + print!("\r[{:02}:{:02}]", mins, secs % 60); + })); + player.on_error(Box::new(|msg| { + eprintln!("[Error] {msg}"); + })); + + // Parse args and load tracks + let is_scan = args.contains(&"--scan".to_string()); + let is_playlist = args.contains(&"--playlist".to_string()); + let path_arg = args + .iter() + .find(|a| !a.starts_with('-') && *a != &args[0]) + .expect("No path provided"); + + if is_scan { + let uris = scanner::scan_directory(Path::new(path_arg)).expect("Scan failed"); + println!("Found {} tracks", uris.len()); + player.load_track_uris(uris); + } else if is_playlist { + let uris = playlist::parse_m3u(Path::new(path_arg)).expect("Playlist parse failed"); + println!("Loaded {} tracks from playlist", uris.len()); + player.load_track_uris(uris); + } else { + player.load_track_uris(vec![path_to_uri(Path::new(path_arg))]); + } + + player.play(); + + println!("Commands: play, pause, stop, next, prev, vol <0-100>, quit"); + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + match line.trim() { + "play" | "p" => player.play(), + "pause" => player.pause(), + "stop" | "s" => player.stop(), + "next" | "n" => player.next(), + "prev" => player.previous(), + "quit" | "q" => { + player.shutdown(); + break; + } + cmd if cmd.starts_with("vol ") => { + if let Ok(vol) = cmd[4..].parse::() { + player.set_volume(vol); + println!("Volume: {vol}"); + } + } + "" => {} + other => println!("Unknown command: {other}"), + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5b8eb8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "rustify" +version = "0.1.0" +description = "Embedded Rust media player for YoyoPod" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] +manifest-path = "bindings/python/Cargo.toml" +python-source = "bindings/python" +module-name = "rustify._rustify"