diff --git a/.cargo/config.toml b/.cargo/config.toml index 6fe3fb9..ce822d5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ +[build] +target-dir = ".cache/cargo-target" + [alias] dev = "run --bin audio-orbit-dev --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2bfbb2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows-quality: + name: Windows hk quality gates + runs-on: windows-latest + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install pinned mise tools + uses: jdx/mise-action@v4 + with: + version: 2026.7.10 + install: true + cache: true + + - name: Run Windows quality gates + run: mise run ci diff --git a/.gitignore b/.gitignore index 3fe905c..29c98da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ +/.cache /target /.idea /.vscode *.rs.bk *.pdb .DS_Store + +# Local developer-tool overrides +hk.local.pkl +mise.local.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75885b8..9ada452 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,5 @@ # Contributing to Audio Orbit - ## Development goals Audio Orbit should stay lightweight, portable, and predictable. Prefer incremental changes over rewrites. @@ -13,28 +12,58 @@ Important priorities: ## Local development -Install a recent Rust toolchain and build the project with Cargo. +Project is currently Windows-only. It uses [mise](https://mise.jdx.dev/) for Rust and developer-tool installation, task execution, and exact local/CI parity on `windows-latest`. + +Install configured tools and repository hooks: + +```sh +mise run setup +``` + +This installs all tools pinned in `mise.toml`. It installs repository Git hooks through hk when `.git` worktree exists; source ZIPs skip hook installation without failing. + +DJ export always has built-in pure-Rust rhythm analysis and WSOLA fallback, so normal builds need no Clang, LLVM, `libclang`, C++ compiler, or `LIBCLANG_PATH`. Optional runtime integrations with Essentia Music Extractor, Rubber Band R3, and Demucs are detected as external executables and are never linked or bundled. + +Available validation commands: ```sh -cargo check -cargo build +mise run format # apply Rust, TOML, and Pkl formatting +mise run format:check # verify Rust, TOML, and Pkl formatting +mise run tooling:check # validate mise tasks and hk/Pkl config +mise run check # cargo check --locked --all-targets +mise run clippy # cargo clippy --locked --bins -- -D warnings +mise run test # cargo nextest run --locked --all-targets +mise run test:doc # doctests not run by nextest +mise run ci # hooks plus full tests used by GitHub Actions ``` -For WSL → Windows sync workflows, edit files in WSL, let Mutagen sync them into the Windows checkout, then run the dev watcher from the Windows checkout, for example `D:\github\rozsazoltan\audio-orbit`: +Git hooks use `hk`: + +- `pre-commit` fixes Rust, TOML, and Pkl formatting; validates TOML, mise, and hk/Pkl config; checks merge markers and private keys +- `pre-push` runs one locked Clippy pass for application binaries on Windows when Rust or Cargo inputs changed; Linux/WSL source worktrees skip compilation +- `mise run hooks:pre-commit` checks the full pre-commit hook against all tracked files +- `mise run hooks:pre-push` runs the fast Windows Clippy gate, or skips compilation on Linux/WSL +- `mise run hooks:check` aliases the full pre-commit check +- `mise run hooks:fix` applies supported pre-commit fixes across all tracked files +- `mise run ci` runs both hooks, nextest, and doctests exactly as Windows GitHub Actions does + +`HK_MISE=1` is used when installing hooks, so generated hook commands execute through `mise` even when shell activation is unavailable. Do not install both global and repository-local hk hooks, because Git can execute both. + +For WSL → Windows sync workflows, edit files in WSL, let Mutagen sync them into Windows checkout, then run dev watcher from Windows checkout, for example `D:\github\rozsazoltan\audio-orbit`: + +Mutagen excludes root `/.cache/` and `/target/` directories. Session configuration is locked when session is created, so rerunning `scripts/setup-mutagen-wsl-dev.ps1` recreates existing named session and applies current ignores. `-KeepExistingSession` preserves old session configuration and should only be used when its ignores are already correct. ```powershell .\scripts\dev.ps1 ``` -You can also run the same watcher directly: +You can also run same watcher directly: ```sh cargo dev ``` -`cargo dev` is a project-local Cargo alias that runs the built-in `audio-orbit-dev` helper. It does not require `cargo-watch`. The helper uses polling-friendly file watching for Mutagen/WSL sync workflows, watches `src`, `Cargo.toml`, `Cargo.lock`, `assets`, and `build.rs`, ignores `target` and portable app data folders, rebuilds `audio-orbit`, and restarts the desktop app after synced file changes. - - +`cargo dev` is project-local Cargo alias running built-in `audio-orbit-dev` helper. Development state uses `.cache/app-data`, so moving Cargo artifacts between `target` and `.cache/cargo-target` does not reset settings, playlists, metadata, or waveforms. It does not require `cargo-watch`. Helper uses polling-friendly file watching for Mutagen/WSL sync workflows, watches `src`, `Cargo.toml`, `Cargo.lock`, `assets`, and `build.rs`, ignores `target` and portable app data folders, rebuilds `audio-orbit`, and restarts desktop app after synced file changes. ## Project structure @@ -78,21 +107,12 @@ refactor(ui): separate settings sections -```text -``` - -The version metadata should be committed as: - -```text -``` - - ## Pull request checklist Before merging, check: -- `cargo check` passes +- `mise run ci` passes - no unexpected version number changes were committed - no unused Rust warnings were introduced - playback still works after profile changes, seeking, crossfade, and media key commands @@ -102,6 +122,7 @@ Before merging, check: By contributing, you agree that your contribution is licensed under the GNU Affero General Public License v3.0 or later. -## Development runner +## Build cache + +Cargo build artifacts are stored under `.cache/cargo-target` to keep the repository root clean. Development app state is stored separately under `.cache/app-data`. These directories are local to machine running Cargo or application and are excluded from Mutagen synchronization. Linux/WSL pre-push does not compile Windows-only application. -Use `cargo dev` from the repository root. The repository contains both `.cargo/config.toml` and `.cargo/config` so Cargo uses the built-in polling dev runner instead of requiring the external `cargo-watch` subcommand. On Windows, `scripts/dev.ps1` runs the same project-local runner directly with `cargo run --bin audio-orbit-dev --`. diff --git a/Cargo.lock b/Cargo.lock index 53b4a33..f9ec298 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -178,7 +178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.0", + "bitflags 2.13.1", "cc", "jni 0.22.4", "libc", @@ -187,7 +187,7 @@ dependencies = [ "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -207,9 +207,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -286,7 +286,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus 5.17.0", + "zbus 5.18.0", ] [[package]] @@ -404,7 +404,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -433,13 +433,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -506,6 +506,7 @@ dependencies = [ "anyhow", "cpal", "directories", + "ebur128", "eframe", "egui", "image", @@ -518,6 +519,7 @@ dependencies = [ "semver", "serde", "serde_json", + "shine-rs", "windows-sys 0.59.0", "winresource", "zip", @@ -541,7 +543,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -550,7 +552,7 @@ dependencies = [ "regex", "rustc-hash 2.1.3", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] [[package]] @@ -576,9 +578,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -652,7 +654,7 @@ checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -698,7 +700,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -712,7 +714,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "polling", "rustix 1.1.4", "slab", @@ -745,9 +747,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.67" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -778,9 +780,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "cgl" @@ -1036,6 +1038,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1068,7 +1079,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1115,7 +1126,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1129,7 +1140,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1162,6 +1173,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "ecolor" version = "0.31.1" @@ -1216,7 +1239,7 @@ checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" dependencies = [ "accesskit", "ahash", - "bitflags 2.13.0", + "bitflags 2.13.1", "emath", "epaint", "log", @@ -1330,7 +1353,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1467,7 +1490,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1487,9 +1510,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1497,15 +1520,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1522,32 +1545,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-io", @@ -1655,7 +1678,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg_aliases", "cgl", "dispatch2", @@ -1721,7 +1744,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "gpu-alloc-types", ] @@ -1731,7 +1754,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -1740,7 +1763,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -1751,7 +1774,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2111,7 +2134,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -2126,7 +2149,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -2154,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2234,7 +2257,7 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.9.0", @@ -2296,7 +2319,7 @@ checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2313,9 +2336,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lucide-icons" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc788cd197371c8775ecd589009350ea71dbc3a5709b2495c032670dcfed320d" +checksum = "029a7582875d762cacb18cb1255779e8efb2fc78081a4fc1ddc65b49b4d42aa0" [[package]] name = "lzma-rs" @@ -2386,7 +2409,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block", "core-graphics-types", "foreign-types", @@ -2440,7 +2463,7 @@ checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg_aliases", "codespan-reporting", "hexf-parse", @@ -2450,7 +2473,7 @@ dependencies = [ "spirv", "strum", "termcolor", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-xid", ] @@ -2460,7 +2483,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -2474,7 +2497,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -2513,7 +2536,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2559,7 +2582,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2599,7 +2622,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2642,7 +2665,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2658,7 +2681,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -2672,7 +2695,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -2696,7 +2719,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2708,7 +2731,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -2719,7 +2742,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -2762,7 +2785,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -2775,7 +2798,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2786,7 +2809,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2809,7 +2832,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2821,7 +2844,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2844,7 +2867,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -2876,7 +2899,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3042,7 +3065,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3080,7 +3103,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3151,9 +3174,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3209,7 +3232,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -3231,7 +3254,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3253,9 +3276,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3378,7 +3401,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3387,7 +3410,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3403,9 +3426,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3415,9 +3438,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3569,7 +3592,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3582,7 +3605,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3678,9 +3701,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3688,22 +3711,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -3721,13 +3744,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -3762,6 +3785,18 @@ dependencies = [ "digest", ] +[[package]] +name = "shine-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6135aba5a2334627cc67e726d20cd42d4218654b11d1467aeac83d977bfc70c1" +dependencies = [ + "hound", + "lazy_static", + "log", + "thiserror 1.0.69", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3786,9 +3821,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -3833,7 +3868,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -3858,7 +3893,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -3866,7 +3901,7 @@ dependencies = [ "log", "memmap2", "rustix 1.1.4", - "thiserror 2.0.18", + "thiserror 2.0.19", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -3915,7 +3950,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3961,7 +3996,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.119", ] [[package]] @@ -4117,9 +4152,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" dependencies = [ "proc-macro2", "quote", @@ -4143,7 +4189,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4179,11 +4225,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -4194,18 +4240,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.0", ] [[package]] @@ -4293,9 +4339,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -4317,9 +4363,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -4341,9 +4387,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -4362,9 +4408,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -4387,7 +4433,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4431,7 +4477,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4548,9 +4594,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.5" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "js-sys", "serde_core", @@ -4639,7 +4685,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -4672,7 +4718,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -4684,7 +4730,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -4706,7 +4752,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -4718,7 +4764,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4731,7 +4777,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4744,7 +4790,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4757,7 +4803,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4825,9 +4871,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -4845,7 +4891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" dependencies = [ "arrayvec", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg_aliases", "document-features", "js-sys", @@ -4871,7 +4917,7 @@ checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" dependencies = [ "arrayvec", "bit-vec", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg_aliases", "document-features", "indexmap", @@ -4883,7 +4929,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wgpu-hal", "wgpu-types", ] @@ -4897,7 +4943,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bitflags 2.13.0", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "core-graphics-types", @@ -4922,7 +4968,7 @@ dependencies = [ "renderdoc-sys", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wasm-bindgen", "web-sys", "wgpu-types", @@ -4935,7 +4981,7 @@ version = "24.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "js-sys", "log", "web-sys", @@ -5023,7 +5069,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5034,7 +5080,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5377,7 +5423,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.0", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -5505,7 +5551,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -5552,7 +5598,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5596,9 +5642,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.17.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-executor", @@ -5624,9 +5670,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow", - "zbus_macros 5.17.0", - "zbus_names 4.3.3", - "zvariant 5.13.0", + "zbus_macros 5.18.0", + "zbus_names 4.3.4", + "zvariant 5.13.1", ] [[package]] @@ -5647,7 +5693,7 @@ checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zbus-lockstep", "zbus_xml", "zvariant 4.2.0", @@ -5662,22 +5708,22 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zbus_macros" -version = "5.17.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", - "zbus_names 4.3.3", - "zvariant 5.13.0", + "syn 2.0.119", + "zbus_names 4.3.4", + "zvariant 5.13.1", "zvariant_utils 3.5.0", ] @@ -5694,13 +5740,13 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.3" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow", - "zvariant 5.13.0", + "zvariant 5.13.1", ] [[package]] @@ -5733,7 +5779,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5753,7 +5799,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5774,7 +5820,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5807,7 +5853,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5832,7 +5878,7 @@ dependencies = [ "memchr", "pbkdf2", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "xz2", "zeroize", @@ -5916,16 +5962,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", "url", "winnow", - "zvariant_derive 5.13.0", + "zvariant_derive 5.13.1", "zvariant_utils 3.5.0", ] @@ -5938,20 +5984,20 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils 2.1.0", ] [[package]] name = "zvariant_derive" -version = "5.13.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils 3.5.0", ] @@ -5963,7 +6009,7 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5975,6 +6021,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.119", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 31e9b99..f4f9e23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ build = "build.rs" anyhow = "1.0" cpal = "0.15" directories = "5.0" +ebur128 = "=0.1.10" eframe = "0.31" egui = "0.31" image = { version = "0.25", default-features = false, features = ["ico", "png"] } @@ -21,6 +22,7 @@ rustfft = "6" serde = { version = "1.0", features = ["derive"] } semver = "1.0" serde_json = "1.0" +shine-rs = "0.1.3" zip = "2.2" windows_sys = { package = "windows-sys", version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_IO", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } diff --git a/README.md b/README.md index a597d19..9db1199 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - [Search tracks](#search-tracks) - [Manage Favorites](#manage-favorites) - [Manage playlist files and multi-select tracks](#manage-playlist-files-and-multi-select-tracks) + - [Build a DJ mix](#build-a-dj-mix) + - [Open audio files and configure file associations](#open-audio-files-and-configure-file-associations) - [Export and import backups](#export-and-import-backups) - [Window behavior](#window-behavior) - [Data location](#data-location) @@ -63,7 +65,8 @@ Audio Orbit supports common desktop-player behavior: - remember the last played local track between app launches - play saved internet radio streams from the Radio tab - favorite radio stations and filter the Radio list to favorites -- select multiple local tracks with Ctrl-click or Shift-click, then copy, add, or delete them together +- select multiple local tracks with Ctrl-click or Shift-click, then copy, add, delete, or mix them together +- export deterministic DJ-style MP3 mixes from full tracks or selected highlights with BPM/downbeat analysis, pitch-preserving tempo sync, phrase-aligned bass swaps, EBU R128 loudness measurement, and transition diagnostics - show a live radio visualizer with elapsed listening time - record the original internet radio stream bytes to timestamped files - remember the window size and position between app launches @@ -194,14 +197,79 @@ Use the heart button next to a track to add or remove it from Favorites. Newly f ### Manage playlist files and multi-select tracks -Use **Export playlist...** in the Library panel to copy every available file from the current playlist into a chosen folder. Export never moves source files and never overwrites an existing destination file; duplicate names receive a numeric suffix. In player-only mode, right-click an empty area of the Music view to access playlist export and whole-playlist file deletion. +Use **Export playlist...** in the Library panel to copy every available file from the current playlist into a chosen folder. Export never moves source files and never overwrites an existing destination file; duplicate names receive a numeric suffix. In full-layout or player-only mode, right-click an empty area of the Music view to access playlist export, whole-playlist file deletion, and DJ mix creation. -Use Ctrl-click to add or remove individual tracks from the current selection. Use Shift-click to select a visible range. Right-click any selected row to copy the selected files into a folder, add all selected tracks to an existing manual playlist, or permanently delete the selected files. +Use Ctrl-click to add or remove individual tracks from the current selection. Use Shift-click to select a visible range. Press Escape to clear the track selection when no menu, search, or modal needs to close first. Right-click any selected row to copy the selected files into a folder, add all selected tracks to an existing manual playlist, or permanently delete the selected files. The final **Add to playlist** submenu item is **New**. It asks for a playlist name, creates a manual playlist, and adds the selected track or tracks. Use **Delete all files...** to permanently delete every file referenced by the current playlist. Multi-track deletion and whole-playlist deletion require typing `DELETE` in a confirmation modal. Successfully deleted files are removed from every playlist that references them. These actions delete files from disk and cannot be undone. +### Build a DJ mix + +Use **DJ mix...** in Library panel to mix current playlist, or select at least two tracks and choose **Create DJ mix...** from track context menu. Same command appears in full-layout and player-only Music view empty-area context menus. + +DJ Mix Builder offers two engines: + +- **Crossfade** keeps original playback speed and joins selected sections with a simple equal-power overlap. +- **Smart DJ** plans phrase-level handoffs instead of fading two complete songs through each other. It analyzes rhythm, downbeats, energy, loudness, and harmonic compatibility; chooses a drum swap, harmonic bridge, echo drop, stem mashup, or custom bridge; keeps one bass owner; and prevents simultaneous lead-vocal handoffs. + +Smart DJ can use an optional professional toolchain already installed on the machine: + +- **Essentia Music Extractor** for BPM, beat positions, confidence, and musical key. +- **Rubber Band R3** for high-quality pitch-preserving tempo matching. +- **Demucs** for drums, bass, accompaniment, and vocal stems. + +No external binary or model is bundled. Audio Orbit detects tools on `PATH` and supports explicit paths through: + +```text +AUDIO_ORBIT_ESSENTIA_PATH +AUDIO_ORBIT_RUBBERBAND_PATH +AUDIO_ORBIT_DEMUCS_PATH +AUDIO_ORBIT_DEMUCS_PYTHON +``` + +Use **Refresh tools** after changing `PATH` or these variables. Missing or failed tools fall back independently to built-in rhythm analysis, deterministic WSOLA tempo matching, full-mix vocal guarding, and deck-derived loops. Export therefore remains functional without the optional toolchain. + +Automatic bridge recipes: + +- **Drum swap** introduces the next groove while the outgoing musical phrase finishes. +- **Harmonic bridge** is selected only for compatible detected keys and uses accompaniment stems or filtered deck material. +- **Echo drop** removes the outgoing phrase with an echo throw and performs a short controlled drop reveal. +- **Stem mashup** combines selected drums/accompaniment while vocals and bass remain mutually exclusive. +- **Custom audio** repeats a chosen MP3/WAV/FLAC/OGG range from a configurable start timestamp and loop length. Loop boundaries use a short overlap crossfade instead of periodic fade-to-zero modulation. + +The complete incoming track does not sit underneath the outgoing track for a long fade. With stems, drums, bass, accompaniment, and vocals receive independent phrase gates. Without stems, a short filtered handoff and center-vocal guard are used. Every Smart DJ transition keeps a low instrumental continuity bed, including echo-drop transitions, so the handoff never reaches a complete mute. + +Requested transition length is a target. When selected sections cannot fit it, Audio Orbit shortens the transition automatically and reserves middle-track audio for both neighboring transitions. The bridge follows the complete available overlap phrase instead of repeatedly cycling a one-bar fragment. Favorite ranges as short as 0.25 seconds remain valid for imported/saved plans; the interactive editor snaps range endpoints to whole seconds. Only unavailable or effectively empty decoded audio fails. + +Set target mix length from 1 to 180 minutes. Each track supports: + +- **Auto highlight**: select a deterministic energetic section and align its boundaries to 8/16/32-bar phrases. +- **Full track**: keep complete playable track after detected leading/trailing silence. +- **Favorite range**: set the usable section on a compact waveform while the track plays. Left-click plays or seeks, right-click moves the range start, and both blue edges are draggable. Moving the start immediately seeks preview playback to the new whole-second position. Range preview stops at the selected end and never advances to the next track. Leaving Favorite range or closing the DJ builder stops the preview and removes the main playback bar. Numeric start/end fields use whole seconds; export still phrase-aligns safe boundaries. + +Smart order keeps the first track and scores following tracks by BPM distance, analysis confidence, harmonic compatibility, and energy continuity. Tempo change is capped at ±6%. Loudness leveling targets approximately -14 LUFS while peak-aware gain and output limiting prevent clipping. MP3 output uses 192/256/320 kbps. + +Export runs in a background worker with stage text, progress, elapsed time, and cancellation. External processes are also cancellable. Analysis, separated stems, and professional tempo-stretch outputs are cached under the application data directory. Cache keys include source path, file size, modification time, selected section, analyzer version, and tempo ratio. + +Every completed export creates: + +```text +mix-name.mp3 +mix-name.dj-plan.json +``` + +Diagnostics record selected sections, analysis backend, BPM/downbeat estimates, key confidence, tempo ratios, gain decisions, actual transition recipes, detected professional tools, fallback warnings, measured output loudness/peaks, and timing data. Use **Show diagnostics** to reveal the report. Use **Play** to add the generated MP3 to **Temporary playback** and start it immediately. + +### Open audio files and configure file associations + +Audio files passed to `audio-orbit.exe` open in the built-in **Temporary playback** playlist and start playing. This also works when Audio Orbit is already running: the new process forwards the requested files to the existing player instead of opening a second window. + +Temporary playback is read-only. Files cannot be added through normal playlist actions, reordered, removed, exported, deleted, or included in DJ mix operations. Its contents and playback resume state are never saved, so the playlist starts empty after every app restart. + +On Windows, open **Settings > File associations** and choose **Associate audio files...**. Audio Orbit registers itself for MP3, WAV, FLAC, OGG, OPUS, M4A, MP4, AAC, AIFF, AIF, APE, and WV files, then opens Windows Default Apps so the final default-app choices can be confirmed. Registration uses the current executable path and does not require administrator rights. Use **Remove registration** to remove Audio Orbit from registered audio-file applications. + ### Export and import backups Open **Settings**, then use **Backup and data**. @@ -220,16 +288,24 @@ Export creates a compressed ZIP backup of the full app state and suggests a file Audio Orbit remembers the window size and position when the app closes and restores the same layout on the next launch. Player-only and full-layout sizes are kept separately, and switching modes restores that mode's own saved width and height. -Only one Audio Orbit instance can run at a time. If the app is already open, starting the executable again exits immediately instead of opening a second player window. +Only one Audio Orbit instance can run at a time. If the app is already open and another invocation contains supported audio files, those files are forwarded to the existing window and played through Temporary playback. Invocations without audio files exit without opening another player window. ## Data location -Audio Orbit stores app data next to the executable: +Release builds store portable app data next to the executable: ```text .audio-orbit-data/state.json ``` +Development runs use a stable project-local path so changing Cargo build directories does not hide settings, playlists, cached metadata, or waveforms: + +```text +.cache/app-data/state.json +``` + +On first development start after this change, Audio Orbit migrates state and existing DJ analysis cache from previous `target/debug/.audio-orbit-data` and `.cache/cargo-target/debug/.audio-orbit-data` locations. + ## Known limitations @@ -251,10 +327,21 @@ Automatic synchronization is scoped to selected folder playlist. Windows wakes A Copyright (C) 2020–present [Zoltán Rózsa](https://github.com/rozsazoltan) +DJ Mix Builder uses built-in pure-Rust rhythm analysis and WSOLA fallback, `ebur128` under MIT for EBU R128 measurement, and `shine-rs` under LGPL-2.0 for MP3 encoding. Optional Essentia, Rubber Band, and Demucs executables are invoked only when separately installed. Redistribution notices are tracked in `THIRD_PARTY_NOTICES.md`. Audio Orbit remains licensed under AGPL-3.0-or-later. + Audio Orbit renders local and live radio waveform bars through a RustFFT-backed amplitude analysis path. The visual design intentionally follows AIMP-like progress bars: neutral gray for the upcoming waveform, blue for the played region, and yellow markers for silence-skip sections. The analyzer still uses spectral information internally to shape a stable loudness envelope, but the UI does not draw colored bass/mid/treble stacks. ## Development runner -Use `cargo dev` from the repository root. The repository contains both `.cargo/config.toml` and `.cargo/config` so Cargo uses the built-in polling dev runner instead of requiring the external `cargo-watch` subcommand. On Windows, `scripts/dev.ps1` runs the same project-local runner directly with `cargo run --bin audio-orbit-dev --`. +Use `cargo dev` from the repository root. Development state stays in `.cache/app-data`, independent from executable build location. Mutagen workflows exclude root `/.cache/` and `/target/`, so Windows build artifacts and development state remain local to Windows mirror. The repository contains `.cargo/config.toml`, so Cargo uses the built-in polling dev runner instead of requiring the external `cargo-watch` subcommand. On Windows, `scripts/dev.ps1` runs the same project-local runner directly with `cargo run --bin audio-orbit-dev --`. + +### DJ mix export responsiveness + +DJ mix rendering runs on a named background worker thread. The export dialog shows an animated activity indicator, current processing stage, elapsed time, percentage, and cancellation control while the main player and library UI remain responsive. + + +### DJ transition behavior + +Smart DJ chooses phrase-aware drum swaps, harmonic bridges, echo drops, stem mashups, or custom looped bridge audio. When Demucs stems are available, it gives drums, bass, accompaniment, and vocals separate handoff curves. Without stems, the full-mix fallback uses short filtered deck handoffs and vocal guarding rather than a long two-song fade. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..6049eca --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,30 @@ +# Third-Party Notices + +Audio Orbit remains licensed under AGPL-3.0-or-later. This file records direct third-party DJ-engine components. Full transitive dependency inventory must still be generated and reviewed from `Cargo.lock` before release. + +## ebur128 + +- Package: `ebur128 0.1.10` +- Source: https://github.com/sdroege/ebur128 +- License: MIT +- Copyright: 2011 Jan Kokemüller; 2020 Sebastian Dröge + +## shine-rs + +- Package: `shine-rs 0.1.3` +- Purpose: MP3 encoding +- License: LGPL-2.0 + +## Models and sidecars + +No ML checkpoint, source-separation model, FFmpeg binary, Mixxx code, Liquidsoap binary, Essentia code/model, Beat This checkpoint, Demucs checkpoint, Open-Unmix checkpoint, Rubber Band code, aubio code, or libKeyFinder code is bundled by this change. + +## Optional professional DJ tools + +Audio Orbit can invoke the following separately installed command-line tools at runtime: + +- Essentia Music Extractor: rhythm, beat-grid, confidence, and musical-key analysis +- Rubber Band R3: pitch-preserving time stretching +- Demucs: drums, bass, accompaniment, and vocal stem separation + +These tools, their source code, binaries, models, Python environments, and transitive dependencies are not distributed in the Audio Orbit archive. Users install and license them separately under their upstream terms. Audio Orbit communicates with them through temporary WAV/JSON files and command-line process execution. Each integration has an independent built-in fallback. diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..cbe6b22 --- /dev/null +++ b/hk.pkl @@ -0,0 +1,63 @@ +amends "package://github.com/jdx/hk/releases/download/v1.51.0/hk@1.51.0#/Config.pkl" + +import "package://github.com/jdx/hk/releases/download/v1.51.0/hk@1.51.0#/Builtins.pkl" + +local rustInputs = List("**/*.rs", "Cargo.toml", "Cargo.lock", ".cargo/**/*.toml", "build.rs") + +local preCommitSteps = new Mapping { + ["rustfmt"] { + glob = List("**/*.rs") + check_first = true + check = "cargo fmt --all -- --check" + fix = "cargo fmt --all" + exclusive = true + } + + ["toml-format"] = (Builtins.tombi_format) { + check_first = true + } + + ["toml-lint"] = Builtins.tombi + ["pkl-format"] = Builtins.pkl_format + + ["mise-config"] { + check = "mise tasks validate --errors-only" + exclusive = true + } + + ["hk-config"] = Builtins.hk_test + ["merge-conflict"] = Builtins.check_merge_conflict + ["private-key"] = Builtins.detect_private_key +} + +local prePushSteps = new Mapping { + ["clippy"] { + glob = rustInputs + check = "mise run clippy:pre-push" + exclusive = true + } +} + +hooks { + ["pre-commit"] { + fix = true + stash = "git" + steps = preCommitSteps + } + + ["pre-push"] { + fix = false + steps = prePushSteps + } + + ["fix"] { + fix = true + stash = "none" + steps = preCommitSteps + } + + ["check"] { + fix = false + steps = preCommitSteps + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..cb1b313 --- /dev/null +++ b/mise.toml @@ -0,0 +1,109 @@ +[tools] +hk = "1.51.0" +pkl = "0.31.1" +rust = { version = "stable", components = ["rustfmt", "clippy"] } +"aqua:nextest-rs/nextest/cargo-nextest" = "0.9.137" +"aqua:tombi-toml/tombi" = "1.1.0" + +[env] +CARGO_INCREMENTAL = "0" +CARGO_TARGET_DIR = ".cache/cargo-target" +CARGO_TERM_COLOR = "always" +AUDIO_ORBIT_APP_DATA_DIR = ".cache/app-data" + +[tasks.setup] +description = "Install configured tools and Git hooks when a Git worktree exists" +run = [ + "mise install", + { task = "hooks:install" }, +] + +[tasks."hooks:install"] +description = "Install repository Git hooks through hk and mise when a Git worktree exists" +dir = "{{config_root}}" +env = { HK_MISE = "1" } +run = """ +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + hk install +else + echo 'Skipping Git hook installation: no .git worktree found.' +fi +""" +run_windows = """ +git rev-parse --is-inside-work-tree >NUL 2>&1 +if errorlevel 1 ( + echo Skipping Git hook installation: no .git worktree found. + exit /b 0 +) +hk install +""" + +[tasks."hooks:pre-commit"] +description = "Run the repository pre-commit hook against all tracked files" +run = "hk run pre-commit --all --check" + +[tasks."hooks:pre-push"] +description = "Run the fast pre-push Clippy gate" +run = "hk run pre-push --all --check" + +[tasks."hooks:check"] +description = "Run pre-commit checks against all tracked files" +run = "mise run hooks:pre-commit" + +[tasks."hooks:fix"] +description = "Run pre-commit auto-fixes against all tracked files" +run = "hk run pre-commit --all --fix" + +[tasks.format] +description = "Format Rust, TOML, and Pkl files" +run = [ + "cargo fmt --all", + "tombi format .", + "pkl format --write hk.pkl", +] + +[tasks."format:check"] +description = "Verify Rust, TOML, and Pkl formatting" +run = [ + "cargo fmt --all -- --check", + "tombi format --check .", + "tombi lint --quiet --error-on-warnings .", + "pkl format --silent hk.pkl", +] + +[tasks."tooling:check"] +description = "Validate mise tasks and hk/Pkl configuration" +run = [ + "mise tasks validate --errors-only", + "hk test --quiet", +] + +[tasks.check] +description = "Type-check all locked Cargo targets" +run = "cargo check --locked --all-targets" + +[tasks.clippy] +description = "Run Clippy against locked application binaries" +run = "cargo clippy --locked --bins -- -D warnings" + +[tasks."clippy:pre-push"] +description = "Run the Windows-only Clippy pre-push gate" +run = "echo 'Skipping Clippy pre-push gate: Audio Orbit development builds are Windows-only.'" +run_windows = "cargo clippy --locked --bins -- -D warnings" + +[tasks.test] +description = "Run Rust tests with cargo-nextest" +run = "cargo nextest run --locked --all-targets" + +[tasks."test:doc"] +description = "Run Rust documentation tests not handled by nextest" +run = "cargo test --locked --doc" + +[tasks.ci] +description = "Run Windows CI quality gates and full test suite" +run = [ + { task = "hooks:pre-commit" }, + { task = "hooks:pre-push" }, + { task = "test" }, + { task = "test:doc" }, +] diff --git a/scripts/setup-mutagen-wsl-dev.ps1 b/scripts/setup-mutagen-wsl-dev.ps1 index 23eefaf..d36c3a4 100644 --- a/scripts/setup-mutagen-wsl-dev.ps1 +++ b/scripts/setup-mutagen-wsl-dev.ps1 @@ -1,6 +1,7 @@ param( [string]$SessionName = "audio-orbit-win-dev", - [string]$WindowsProjectPath + [string]$WindowsProjectPath, + [switch]$KeepExistingSession ) $ErrorActionPreference = "Stop" @@ -13,6 +14,24 @@ function Get-WorkspaceRoot { return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path } +function New-AudioOrbitMutagenSession { + param( + [string]$Name, + [string]$Source, + [string]$Target + ) + + mutagen sync create ` + --name $Name ` + --sync-mode two-way-safe ` + --ignore-vcs ` + --ignore "/.cache/" ` + --ignore "/target/" ` + --ignore "*.zip" ` + $Source ` + $Target +} + if (-not (Get-Command mutagen -ErrorAction SilentlyContinue)) { throw "Mutagen was not found on PATH. Install mutagen.exe on Windows first, then reopen PowerShell." } @@ -48,30 +67,30 @@ New-Item -ItemType Directory -Force -Path $TargetFullPath | Out-Null $ExistingSession = mutagen sync list --long 2>$null | Select-String -SimpleMatch "Name: $SessionName" if ($ExistingSession) { - Write-Host "Mutagen session '$SessionName' already exists." - Write-Host "Use 'mutagen sync monitor $SessionName' to watch it, or terminate it first if you want to recreate it." + if ($KeepExistingSession) { + Write-Host "Mutagen session '$SessionName' already exists; keeping its locked configuration." + Write-Warning "Existing session may not exclude /.cache/. Re-run without -KeepExistingSession to apply current ignores." + } else { + Write-Host "Recreating Mutagen session '$SessionName' so current ignore rules apply." + mutagen sync terminate $SessionName + New-AudioOrbitMutagenSession -Name $SessionName -Source $SourceFullPath -Target $TargetFullPath + } } else { - mutagen sync create ` - --name $SessionName ` - --sync-mode two-way-safe ` - --ignore-vcs ` - --ignore ".cache" ` - --ignore "target" ` - --ignore "*.zip" ` - $SourceFullPath ` - $TargetFullPath + New-AudioOrbitMutagenSession -Name $SessionName -Source $SourceFullPath -Target $TargetFullPath } Write-Host "" Write-Host "Source workspace: $SourceFullPath" Write-Host "Windows mirror: $TargetFullPath" Write-Host "Mutagen session: $SessionName" +Write-Host "Ignored roots: /.cache/ and /target/" Write-Host "" Write-Host "Run the Windows dev app from PowerShell:" Write-Host " cd $TargetFullPath" Write-Host " cargo dev" Write-Host "" Write-Host "Keep Git operations on the source workspace side." +Write-Host "Linux/WSL pre-push skips Cargo compilation because Audio Orbit builds are Windows-only." Write-Host "" Write-Host "Useful Mutagen commands:" Write-Host " mutagen sync list" diff --git a/src/app/core.rs b/src/app/core.rs index 4047231..085cc51 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -40,6 +40,8 @@ impl AudioOrbitApp { pending_folder_scan_receiver: None, pending_library_sync_receiver: None, pending_track_file_operation_receiver: None, + dj_mix_event_receiver: None, + dj_mix_cancel_flag: None, folder_watcher: None, folder_watcher_target_key: None, pending_folder_watch_sync_at: None, @@ -56,6 +58,7 @@ impl AudioOrbitApp { pending_new_playlist_tracks: Vec::new(), pending_track_delete_confirmation: None, pending_track_delete_confirmation_text: String::new(), + dj_mix_modal: None, active_panel_modal: None, panel_modal_history: Vec::new(), details_modal: None, @@ -107,6 +110,9 @@ impl AudioOrbitApp { last_update_check: None, update_check_started_at: None, update_install_started_at: None, + last_external_open_request_poll: Instant::now(), + #[cfg(windows)] + file_associations_registered: file_associations::is_registered(), #[cfg(debug_assertions)] dev_metrics: DevMetricsPanelState::default(), #[cfg(debug_assertions)] @@ -142,6 +148,8 @@ impl AudioOrbitApp { pending_folder_scan_receiver: None, pending_library_sync_receiver: None, pending_track_file_operation_receiver: None, + dj_mix_event_receiver: None, + dj_mix_cancel_flag: None, folder_watcher: None, folder_watcher_target_key: None, pending_folder_watch_sync_at: None, @@ -158,6 +166,7 @@ impl AudioOrbitApp { pending_new_playlist_tracks: Vec::new(), pending_track_delete_confirmation: None, pending_track_delete_confirmation_text: String::new(), + dj_mix_modal: None, active_panel_modal: None, panel_modal_history: Vec::new(), details_modal: None, @@ -209,6 +218,9 @@ impl AudioOrbitApp { last_update_check: None, update_check_started_at: None, update_install_started_at: None, + last_external_open_request_poll: Instant::now(), + #[cfg(windows)] + file_associations_registered: file_associations::is_registered(), #[cfg(debug_assertions)] dev_metrics: DevMetricsPanelState::default(), #[cfg(debug_assertions)] @@ -279,19 +291,29 @@ impl AudioOrbitApp { geometry .filter(WindowGeometry::is_valid) - .map(|geometry| egui::vec2(geometry.width.max(min_size.x), geometry.height.max(min_size.y))) + .map(|geometry| { + egui::vec2( + geometry.width.max(min_size.x), + geometry.height.max(min_size.y), + ) + }) .unwrap_or_else(|| default_window_size_for_mode(player_only_mode)) } pub(crate) fn apply_window_mode_size(&self, context: &egui::Context, player_only_mode: bool) { - context.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min_window_size_for_mode(player_only_mode))); - context.send_viewport_cmd(egui::ViewportCommand::InnerSize(self.saved_window_size_for_mode(player_only_mode))); + context.send_viewport_cmd(egui::ViewportCommand::MinInnerSize( + min_window_size_for_mode(player_only_mode), + )); + context.send_viewport_cmd(egui::ViewportCommand::InnerSize( + self.saved_window_size_for_mode(player_only_mode), + )); } pub(crate) fn toggle_player_only_mode(&mut self, context: &egui::Context) { self.remember_window_geometry(context); self.player_only_mode = !self.player_only_mode; self.state.ui.player_only_mode = self.player_only_mode; self.apply_window_mode_size(context, self.player_only_mode); - self.suppress_window_geometry_save_until = Some(Instant::now() + Duration::from_millis(450)); + self.suppress_window_geometry_save_until = + Some(Instant::now() + Duration::from_millis(450)); self.save_state_silently(); } pub(crate) fn open_panel_modal(&mut self, panel: AppPanelModal) { @@ -331,7 +353,17 @@ impl AudioOrbitApp { return; } - if self.active_panel_modal.is_some() { + if context.memory(|memory| memory.any_popup_open()) { + return; + } + + if self.dj_mix_modal.is_some() { + if self.dj_mix_is_running() { + self.cancel_dj_mix_export(); + } else { + self.dj_mix_modal = None; + } + } else if self.active_panel_modal.is_some() { self.close_panel_modal(); } else if self.show_folder_import_modal { self.show_folder_import_modal = false; @@ -350,6 +382,11 @@ impl AudioOrbitApp { self.close_track_search(); } else if self.show_radio_search { self.close_radio_search(); + } else if self.selected_track_index.is_some() + || !self.multi_selected_track_indexes.is_empty() + { + self.clear_multi_track_selection(); + self.selected_track_index = None; } } pub(crate) fn save_state_silently(&mut self) { @@ -363,7 +400,9 @@ impl AudioOrbitApp { if self.status_message != self.status_last_seen { self.status_last_seen = self.status_message.clone(); self.status_updated_at = Instant::now(); - } else if !self.status_message.is_empty() && self.status_updated_at.elapsed() >= Duration::from_secs(10) { + } else if !self.status_message.is_empty() + && self.status_updated_at.elapsed() >= Duration::from_secs(10) + { self.status_message.clear(); self.status_last_seen.clear(); self.status_updated_at = Instant::now(); @@ -372,7 +411,9 @@ impl AudioOrbitApp { if self.error_message != self.error_last_seen { self.error_last_seen = self.error_message.clone(); self.error_updated_at = Instant::now(); - } else if self.error_message.is_some() && self.error_updated_at.elapsed() >= Duration::from_secs(10) { + } else if self.error_message.is_some() + && self.error_updated_at.elapsed() >= Duration::from_secs(10) + { self.error_message = None; self.error_last_seen = None; self.error_updated_at = Instant::now(); diff --git a/src/app/dev_metrics.rs b/src/app/dev_metrics.rs index 9c976fa..a6be8fb 100644 --- a/src/app/dev_metrics.rs +++ b/src/app/dev_metrics.rs @@ -1,9 +1,7 @@ use crate::*; #[cfg(debug_assertions)] -use std::{ - process::Child, -}; +use std::process::Child; #[cfg(debug_assertions)] const DEV_METRICS_PROCESS_ARG: &str = "--audio-orbit-dev-metrics"; @@ -82,7 +80,10 @@ impl DevMetricsNativeWindowHandle { .spawn() .map_err(|error| format!("failed to open Dev metrics window: {error}"))?; - Ok(Self { child, snapshot_path }) + Ok(Self { + child, + snapshot_path, + }) } fn is_running(&mut self) -> bool { @@ -205,9 +206,11 @@ impl DevMetricsStandaloneApp { if elapsed > 0.0 { let process_seconds = sample .process_time_100ns - .saturating_sub(previous.process_time_100ns) as f64 + .saturating_sub(previous.process_time_100ns) + as f64 / 10_000_000.0; - let normalized = process_seconds / elapsed / self.logical_processors as f64 * 100.0; + let normalized = + process_seconds / elapsed / self.logical_processors as f64 * 100.0; self.snapshot.cpu_percent = Some(normalized.clamp(0.0, 100.0) as f32); } } @@ -222,7 +225,6 @@ impl DevMetricsStandaloneApp { self.snapshot.peak_working_set_bytes = None; self.snapshot.pagefile_bytes = None; } - } } @@ -298,7 +300,11 @@ impl Default for DevMetricsPanelState { #[cfg(debug_assertions)] impl AudioOrbitApp { - pub(crate) fn update_dev_metrics(&mut self, context: &egui::Context, repaint_interval: Duration) { + pub(crate) fn update_dev_metrics( + &mut self, + context: &egui::Context, + repaint_interval: Duration, + ) { if !self.show_dev_metrics_window { return; } @@ -322,7 +328,9 @@ impl AudioOrbitApp { self.dev_metrics.snapshot.player_state = self.dev_player_state_label().to_owned(); self.dev_metrics.snapshot.uptime_seconds = context.input(|input| input.time as f32); - if now.saturating_duration_since(self.dev_metrics.last_refresh_at) < Duration::from_millis(750) { + if now.saturating_duration_since(self.dev_metrics.last_refresh_at) + < Duration::from_millis(750) + { return; } self.dev_metrics.last_refresh_at = now; @@ -335,14 +343,19 @@ impl AudioOrbitApp { return; }; - if let (Some(previous), Some(previous_at)) = (self.dev_metrics.last_sample, self.dev_metrics.last_sample_at) { + if let (Some(previous), Some(previous_at)) = ( + self.dev_metrics.last_sample, + self.dev_metrics.last_sample_at, + ) { let elapsed = now.saturating_duration_since(previous_at).as_secs_f64(); if elapsed > 0.0 { let process_seconds = sample .process_time_100ns - .saturating_sub(previous.process_time_100ns) as f64 + .saturating_sub(previous.process_time_100ns) + as f64 / 10_000_000.0; - let normalized = process_seconds / elapsed / self.dev_metrics.logical_processors as f64 * 100.0; + let normalized = + process_seconds / elapsed / self.dev_metrics.logical_processors as f64 * 100.0; self.dev_metrics.snapshot.cpu_percent = Some(normalized.clamp(0.0, 100.0) as f32); } } @@ -392,7 +405,12 @@ impl AudioOrbitApp { fn dev_player_state_label(&self) -> &'static str { if self.active_radio_index.is_some() { "radio" - } else if self.player.as_ref().map(AudioPlayer::is_playing).unwrap_or(false) { + } else if self + .player + .as_ref() + .map(AudioPlayer::is_playing) + .unwrap_or(false) + { "music playing" } else if self.active_track_path.is_some() { "music selected" @@ -401,7 +419,6 @@ impl AudioOrbitApp { } } - pub(crate) fn render_dev_metrics_window(&mut self, _context: &egui::Context) { if !self.show_dev_metrics_window { return; @@ -451,11 +468,14 @@ impl AudioOrbitApp { .map(|playlist| playlist.tracks.len()) .sum() } - } #[cfg(debug_assertions)] -fn render_dev_metrics_panel_content(ui: &mut egui::Ui, snapshot: &DevMetricsSnapshot, counters: &DevMetricsCounters) { +fn render_dev_metrics_panel_content( + ui: &mut egui::Ui, + snapshot: &DevMetricsSnapshot, + counters: &DevMetricsCounters, +) { AudioOrbitApp::render_modal_section(ui, |ui| { ui.heading("Dev runtime metrics"); ui.small("Debug-only process metrics for checking CPU, memory, repaint cadence, and active background work while profiling Audio Orbit."); @@ -464,10 +484,38 @@ fn render_dev_metrics_panel_content(ui: &mut egui::Ui, snapshot: &DevMetricsSnap AudioOrbitApp::render_modal_section(ui, |ui| { ui.heading("Process"); - metric_row(ui, "CPU", snapshot.cpu_percent.map(|value| format!("{value:.1}%")).unwrap_or_else(|| "Waiting for sample".to_owned())); - metric_row(ui, "RAM", snapshot.working_set_bytes.map(format_bytes).unwrap_or_else(|| "Unavailable".to_owned())); - metric_row(ui, "Peak RAM", snapshot.peak_working_set_bytes.map(format_bytes).unwrap_or_else(|| "Unavailable".to_owned())); - metric_row(ui, "Commit", snapshot.pagefile_bytes.map(format_bytes).unwrap_or_else(|| "Unavailable".to_owned())); + metric_row( + ui, + "CPU", + snapshot + .cpu_percent + .map(|value| format!("{value:.1}%")) + .unwrap_or_else(|| "Waiting for sample".to_owned()), + ); + metric_row( + ui, + "RAM", + snapshot + .working_set_bytes + .map(format_bytes) + .unwrap_or_else(|| "Unavailable".to_owned()), + ); + metric_row( + ui, + "Peak RAM", + snapshot + .peak_working_set_bytes + .map(format_bytes) + .unwrap_or_else(|| "Unavailable".to_owned()), + ); + metric_row( + ui, + "Commit", + snapshot + .pagefile_bytes + .map(format_bytes) + .unwrap_or_else(|| "Unavailable".to_owned()), + ); metric_row(ui, "GPU", snapshot.gpu_usage_label.clone()); ui.small("GPU usage is not polled directly here to avoid adding a high-overhead Windows performance-counter loop to normal profiling runs. Use Task Manager or GPUView for exact per-adapter GPU counters."); }); @@ -475,9 +523,21 @@ fn render_dev_metrics_panel_content(ui: &mut egui::Ui, snapshot: &DevMetricsSnap AudioOrbitApp::render_modal_section(ui, |ui| { ui.heading("UI / repaint"); - metric_row(ui, "Frame delta", format!("{:.1} ms", snapshot.frame_delta_ms)); - metric_row(ui, "Estimated FPS", format!("{:.1}", snapshot.estimated_fps)); - metric_row(ui, "Next repaint", format!("{} ms", snapshot.repaint_interval_ms)); + metric_row( + ui, + "Frame delta", + format!("{:.1} ms", snapshot.frame_delta_ms), + ); + metric_row( + ui, + "Estimated FPS", + format!("{:.1}", snapshot.estimated_fps), + ); + metric_row( + ui, + "Next repaint", + format!("{} ms", snapshot.repaint_interval_ms), + ); metric_row(ui, "App uptime", format_duration(snapshot.uptime_seconds)); metric_row(ui, "Player state", snapshot.player_state.clone()); let jobs = if snapshot.background_jobs.is_empty() { @@ -541,10 +601,7 @@ fn collect_process_metrics_sample() -> Option { #[cfg(all(debug_assertions, windows))] fn collect_process_metrics_sample_for_pid(pid: u32) -> Option { - use windows_sys::Win32::{ - Foundation::CloseHandle, - System::Threading::OpenProcess, - }; + use windows_sys::Win32::{Foundation::CloseHandle, System::Threading::OpenProcess}; const PROCESS_QUERY_INFORMATION: u32 = 0x0400; const PROCESS_VM_READ: u32 = 0x0010; @@ -561,7 +618,9 @@ fn collect_process_metrics_sample_for_pid(pid: u32) -> Option Option { +unsafe fn collect_process_metrics_sample_for_handle( + process: windows_sys::Win32::Foundation::HANDLE, +) -> Option { use windows_sys::Win32::{ Foundation::FILETIME, System::{ diff --git a/src/app/dj_mix.rs b/src/app/dj_mix.rs new file mode 100644 index 0000000..830ff0a --- /dev/null +++ b/src/app/dj_mix.rs @@ -0,0 +1,1218 @@ +use crate::*; +use std::sync::atomic::Ordering; + +#[derive(Default)] +struct DjFavoriteRangeWaveformAction { + seek_seconds: Option, + start_changed: bool, + end_changed: bool, +} + +fn snap_favorite_range_second(seconds: f32, duration_seconds: f32) -> f32 { + seconds.round().clamp(0.0, duration_seconds) +} + +fn draw_dj_favorite_range_waveform( + ui: &mut egui::Ui, + id: egui::Id, + waveform: &[f32], + duration_seconds: f32, + range_start_seconds: &mut f32, + range_end_seconds: &mut f32, + playhead_seconds: Option, + enabled: bool, +) -> DjFavoriteRangeWaveformAction { + let duration_seconds = duration_seconds.max(0.25).round().max(1.0); + let minimum_range_seconds = 1.0_f32.min(duration_seconds); + *range_start_seconds = snap_favorite_range_second(*range_start_seconds, duration_seconds) + .clamp(0.0, (duration_seconds - minimum_range_seconds).max(0.0)); + *range_end_seconds = snap_favorite_range_second(*range_end_seconds, duration_seconds).clamp( + (*range_start_seconds + minimum_range_seconds).min(duration_seconds), + duration_seconds, + ); + + let desired_size = egui::vec2(ui.available_width().max(180.0).floor(), 44.0); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + let seconds_from_x = |x: f32| { + snap_favorite_range_second( + ((x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0) * duration_seconds, + duration_seconds, + ) + }; + let x_from_seconds = + |seconds: f32| rect.left() + rect.width() * (seconds / duration_seconds).clamp(0.0, 1.0); + + let initial_start_x = x_from_seconds(*range_start_seconds); + let initial_end_x = x_from_seconds(*range_end_seconds); + let handle_width = 14.0; + let start_hit_rect = egui::Rect::from_min_max( + egui::pos2(initial_start_x - handle_width * 0.5, rect.top()), + egui::pos2(initial_start_x + handle_width * 0.5, rect.bottom()), + ); + let end_hit_rect = egui::Rect::from_min_max( + egui::pos2(initial_end_x - handle_width * 0.5, rect.top()), + egui::pos2(initial_end_x + handle_width * 0.5, rect.bottom()), + ); + let handle_sense = if enabled { + egui::Sense::drag() + } else { + egui::Sense::hover() + }; + let start_response = ui.interact(start_hit_rect, id.with("start"), handle_sense); + let end_response = ui.interact(end_hit_rect, id.with("end"), handle_sense); + let mut action = DjFavoriteRangeWaveformAction::default(); + + if enabled && start_response.dragged() { + if let Some(pointer) = start_response.interact_pointer_pos() { + *range_start_seconds = seconds_from_x(pointer.x) + .clamp(0.0, (*range_end_seconds - minimum_range_seconds).max(0.0)); + } + } + if enabled && end_response.dragged() { + if let Some(pointer) = end_response.interact_pointer_pos() { + *range_end_seconds = seconds_from_x(pointer.x).clamp( + (*range_start_seconds + minimum_range_seconds).min(duration_seconds), + duration_seconds, + ); + } + } + action.start_changed = enabled && start_response.drag_stopped(); + action.end_changed = enabled && end_response.drag_stopped(); + + let handles_active = start_response.hovered() + || end_response.hovered() + || start_response.dragged() + || end_response.dragged(); + if enabled && response.secondary_clicked() && !handles_active { + if let Some(pointer) = response.interact_pointer_pos() { + let new_start = seconds_from_x(pointer.x) + .clamp(0.0, (duration_seconds - minimum_range_seconds).max(0.0)); + *range_start_seconds = new_start; + if *range_end_seconds - *range_start_seconds < minimum_range_seconds { + *range_end_seconds = + (*range_start_seconds + minimum_range_seconds).min(duration_seconds); + action.end_changed = true; + } + action.start_changed = true; + } + } + + let painter = ui.painter(); + painter.rect_filled(rect, 3.0, egui::Color32::from_black_alpha(220)); + if waveform.is_empty() { + painter.line_segment( + [ + egui::pos2(rect.left() + 6.0, rect.center().y), + egui::pos2(rect.right() - 6.0, rect.center().y), + ], + egui::Stroke::new(1.0, egui::Color32::from_rgb(74, 82, 96)), + ); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "Play preview to load duration + waveform", + egui::FontId::proportional(11.0), + egui::Color32::from_rgb(132, 140, 154), + ); + } else { + let column_count = (rect.width() / 3.0).floor().max(1.0) as usize + 1; + let values = (0..column_count) + .map(|column| sample_waveform_column(waveform, column, column_count)) + .collect::>(); + let peak = values.iter().copied().fold(0.0_f32, f32::max).max(0.08); + let center_y = rect.center().y.round(); + for (column, value) in values.iter().enumerate() { + let x = rect.left() + column as f32 * 3.0; + if x > rect.right() { + break; + } + let normalized = (*value / peak).clamp(0.025, 1.0).powf(1.05); + let height = (rect.height() * 0.78 * normalized) + .max(2.0) + .min(rect.height() - 6.0); + painter.line_segment( + [ + egui::pos2(x, center_y - height * 0.5), + egui::pos2(x, center_y + height * 0.5), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgb(94, 103, 118)), + ); + } + } + + let start_x = x_from_seconds(*range_start_seconds); + let end_x = x_from_seconds(*range_end_seconds); + let selected_rect = egui::Rect::from_min_max( + egui::pos2(start_x, rect.top()), + egui::pos2(end_x, rect.bottom()), + ); + painter.rect_filled( + selected_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(64, 126, 236, 48), + ); + if start_x > rect.left() { + painter.rect_filled( + egui::Rect::from_min_max(rect.min, egui::pos2(start_x, rect.bottom())), + 0.0, + egui::Color32::from_black_alpha(105), + ); + } + if end_x < rect.right() { + painter.rect_filled( + egui::Rect::from_min_max(egui::pos2(end_x, rect.top()), rect.max), + 0.0, + egui::Color32::from_black_alpha(105), + ); + } + + let marker_color = egui::Color32::from_rgb(100, 166, 255); + for x in [start_x, end_x] { + painter.line_segment( + [ + egui::pos2(x, rect.top() + 2.0), + egui::pos2(x, rect.bottom() - 2.0), + ], + egui::Stroke::new(2.0, marker_color), + ); + painter.circle_filled(egui::pos2(x, rect.top() + 5.0), 4.0, marker_color); + painter.circle_filled(egui::pos2(x, rect.bottom() - 5.0), 4.0, marker_color); + } + + if let Some(playhead_seconds) = playhead_seconds { + let playhead_x = x_from_seconds(playhead_seconds.clamp(0.0, duration_seconds)); + painter.line_segment( + [ + egui::pos2(playhead_x, rect.top() + 1.0), + egui::pos2(playhead_x, rect.bottom() - 1.0), + ], + egui::Stroke::new(1.0, egui::Color32::WHITE), + ); + } + + let response = response.on_hover_text( + "Left click: play/seek · Right click: set range start · Drag blue handles: resize range", + ); + start_response.on_hover_text("Drag favorite range start"); + end_response.on_hover_text("Drag favorite range end"); + + if enabled && response.clicked_by(egui::PointerButton::Primary) && !handles_active { + action.seek_seconds = response + .interact_pointer_pos() + .map(|pointer| seconds_from_x(pointer.x)); + } + + action +} + +impl AudioOrbitApp { + pub(crate) fn dj_mix_is_running(&self) -> bool { + self.dj_mix_event_receiver.is_some() + } + + pub(crate) fn stop_dj_preview_playback(&mut self) { + let preview_path = self + .dj_mix_modal + .as_ref() + .and_then(|modal| modal.preview_track_path.clone()); + if preview_path.is_some() { + self.stop(); + } + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.preview_track_path = None; + modal.preview_stop_seconds = None; + } + } + + fn dj_preview_waveform_for_path(&self, path: &Path) -> (Vec, Option) { + if let Some(playback) = self + .last_playback + .as_ref() + .filter(|playback| same_path(&playback.path, path)) + { + return ( + playback.waveform.clone(), + Some(playback.original_duration_seconds), + ); + } + + self.state + .playlists + .iter() + .flat_map(|playlist| playlist.tracks.iter()) + .find(|track| same_path(&track.path, path)) + .map(|track| { + ( + track.waveform.clone(), + track + .metadata + .duration_seconds + .filter(|seconds| seconds.is_finite() && *seconds > 0.0), + ) + }) + .unwrap_or_default() + } + + pub(crate) fn open_dj_mix_builder_for_current_playlist(&mut self) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } + + let tracks = self + .current_playlist() + .map(|playlist| { + playlist + .tracks + .iter() + .filter(|track| !track.missing && track.path.is_file()) + .map(|track| DjMixTrack::new(track.path.clone(), track.title.clone())) + .collect::>() + }) + .unwrap_or_default(); + self.open_dj_mix_builder(tracks); + } + + pub(crate) fn open_dj_mix_builder_for_selection(&mut self, context_index: usize) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } + + let selected_paths = self + .action_track_paths_for_context(context_index) + .into_iter() + .map(|path| path_key(&path)) + .collect::>(); + let tracks = self + .current_playlist() + .map(|playlist| { + playlist + .tracks + .iter() + .filter(|track| { + selected_paths.contains(&path_key(&track.path)) + && !track.missing + && track.path.is_file() + }) + .map(|track| DjMixTrack::new(track.path.clone(), track.title.clone())) + .collect::>() + }) + .unwrap_or_default(); + self.open_dj_mix_builder(tracks); + } + + fn open_dj_mix_builder(&mut self, tracks: Vec) { + if self.dj_mix_is_running() { + self.error_message = Some("DJ mix export already running.".to_owned()); + return; + } + if tracks.len() < 2 { + self.error_message = Some("DJ mix requires at least two available tracks.".to_owned()); + return; + } + self.active_panel_modal = None; + self.panel_modal_history.clear(); + self.dj_mix_modal = Some(DjMixModalState { + tracks, + options: DjMixOptions::default(), + custom_bridge_path: None, + stage: "Ready".to_owned(), + progress: 0.0, + output_path: None, + report_path: None, + diagnostics_summary: None, + professional_tool_status: dj_mix::professional_tool_status_summary(), + completed: false, + started_at: None, + last_progress_at: None, + preview_track_path: None, + preview_stop_seconds: None, + }); + } + + pub(crate) fn process_dj_mix_events(&mut self) { + let (events, disconnected) = match &self.dj_mix_event_receiver { + Some(receiver) => { + let mut events = Vec::new(); + let mut disconnected = false; + loop { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + (events, disconnected) + } + None => return, + }; + + for event in events { + match event { + DjMixEvent::Progress { stage, progress } => { + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = stage; + modal.progress = progress; + modal.last_progress_at = Some(Instant::now()); + } + } + DjMixEvent::Completed { + output_path, + report_path, + track_count, + duration_seconds, + integrated_lufs, + true_peak_dbfs, + diagnostics_summary, + } => { + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = format!( + "Complete — {track_count} tracks, {:.1} min", + duration_seconds / 60.0 + ); + modal.progress = 1.0; + modal.output_path = Some(output_path.clone()); + modal.report_path = Some(report_path); + modal.diagnostics_summary = Some(diagnostics_summary); + modal.completed = true; + modal.last_progress_at = Some(Instant::now()); + } + self.status_message = format!( + "DJ mix saved: {}{}{}", + output_path.display(), + integrated_lufs + .map(|value| format!(" — {value:.1} LUFS")) + .unwrap_or_default(), + true_peak_dbfs + .map(|value| format!(", {value:.1} dBTP")) + .unwrap_or_default(), + ); + self.dj_mix_event_receiver = None; + self.dj_mix_cancel_flag = None; + } + DjMixEvent::Cancelled => { + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Cancelled".to_owned(); + modal.progress = 0.0; + } + self.status_message = "DJ mix export cancelled.".to_owned(); + self.dj_mix_event_receiver = None; + self.dj_mix_cancel_flag = None; + } + DjMixEvent::Failed(error) => { + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Failed".to_owned(); + modal.progress = 0.0; + } + self.error_message = Some(error); + self.dj_mix_event_receiver = None; + self.dj_mix_cancel_flag = None; + } + } + } + + if disconnected && self.dj_mix_event_receiver.is_some() { + self.dj_mix_event_receiver = None; + self.dj_mix_cancel_flag = None; + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Failed".to_owned(); + modal.progress = 0.0; + } + self.error_message = Some("DJ mix worker stopped before completing export.".to_owned()); + } + } + + pub(crate) fn cancel_dj_mix_export(&mut self) { + if let Some(cancel) = &self.dj_mix_cancel_flag { + cancel.store(true, Ordering::Relaxed); + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Cancelling...".to_owned(); + } + } + } + + fn start_dj_mix_export(&mut self) { + if self.dj_mix_is_running() { + return; + } + let Some(modal) = self.dj_mix_modal.as_ref() else { + return; + }; + if modal.tracks.len() < 2 { + self.error_message = Some("DJ mix requires at least two tracks.".to_owned()); + return; + } + + let default_name = self.default_dj_mix_file_name(); + let Some(output_path) = FileDialog::new() + .add_filter("MP3 audio", &["mp3"]) + .set_file_name(default_name) + .save_file() + else { + return; + }; + + if modal.options.bridge_mode == DjBridgeMode::Custom + && modal + .custom_bridge_path + .as_ref() + .map(|path| !path.is_file()) + .unwrap_or(true) + { + self.error_message = Some("Choose an available custom bridge audio file.".to_owned()); + return; + } + + let request = dj_mix::ExportRequest { + tracks: modal.tracks.clone(), + options: modal.options, + custom_bridge_path: modal.custom_bridge_path.clone(), + output_path, + }; + let (sender, receiver) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + self.dj_mix_event_receiver = Some(receiver); + self.dj_mix_cancel_flag = Some(cancel); + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Starting analysis...".to_owned(); + modal.progress = 0.0; + modal.output_path = None; + modal.report_path = None; + modal.diagnostics_summary = None; + modal.completed = false; + modal.started_at = Some(Instant::now()); + modal.last_progress_at = Some(Instant::now()); + } + let spawn_result = thread::Builder::new() + .name("audio-orbit-dj-export".to_owned()) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + dj_mix::export_mix(request, sender.clone(), worker_cancel) + })); + if result.is_err() { + let _ = sender.send(DjMixEvent::Failed( + "DJ mix worker crashed unexpectedly.".to_owned(), + )); + } + }); + if let Err(error) = spawn_result { + self.dj_mix_event_receiver = None; + self.dj_mix_cancel_flag = None; + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.stage = "Failed".to_owned(); + modal.started_at = None; + } + self.error_message = Some(format!("Failed to start DJ mix worker: {error}")); + } + } + + fn default_dj_mix_file_name(&self) -> String { + let playlist_name = self + .current_playlist() + .map(|playlist| playlist.name.as_str()) + .unwrap_or("playlist"); + let safe_name = playlist_name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ' ') { + character + } else { + '_' + } + }) + .collect::() + .trim() + .to_owned(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + format!( + "{}-dj-mix-{timestamp}.mp3", + if safe_name.is_empty() { + "playlist" + } else { + &safe_name + } + ) + } + + pub(crate) fn render_dj_mix_modal(&mut self, context: &egui::Context) { + let Some(mut modal) = self.dj_mix_modal.clone() else { + return; + }; + let running = self.dj_mix_is_running(); + self.render_modal_backdrop(context, "dj_mix_modal_backdrop"); + let screen_rect = context.screen_rect(); + let outer_padding = Self::modal_outer_padding(screen_rect); + let footer_height = self.modal_info_footer_reserved_height(); + let content_size = Self::modal_content_size(screen_rect, outer_padding, footer_height); + let body_scroll_height = (content_size.y - 96.0).max(120.0); + let track_scroll_height = (content_size.y * 0.38).clamp(120.0, 360.0); + let mut close_requested = false; + let mut start_requested = false; + let mut cancel_requested = false; + let mut reveal_path = None; + let mut play_path = None; + let mut choose_bridge_requested = false; + let mut clear_bridge_requested = false; + let mut preview_play_request: Option<(PathBuf, f32, f32)> = None; + let mut preview_seek_request: Option<(f32, f32)> = None; + let mut preview_stop_update_request: Option<(PathBuf, f32)> = None; + let mut preview_pause_resume_requested = false; + let mut stop_preview_requested = false; + + egui::Area::new(egui::Id::new("dj_mix_modal")) + .order(egui::Order::Foreground) + .fixed_pos(screen_rect.left_top()) + .show(context, |ui| { + Self::modal_panel_frame(outer_padding).show(ui, |ui| { + ui.set_min_size(content_size); + ui.set_max_width(content_size.x); + if Self::render_modal_header( + ui, + outer_padding.x, + Icon::Music, + "DJ Mix Builder", + "Build an offline DJ set with phrase-aware planning, stem-aware handoffs, professional analysis/time-stretch tools when installed, and deterministic fallbacks.", + ) { + if running { + cancel_requested = true; + } else { + close_requested = true; + } + } + + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(outer_padding.x as i8, 10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical() + .id_salt("dj_mix_modal_body") + .max_height(body_scroll_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + Self::render_modal_section(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.label("Mix engine:"); + ui.add_enabled_ui(!running, |ui| { + ui.selectable_value( + &mut modal.options.style, + DjMixStyle::Crossfade, + "Crossfade", + ); + ui.selectable_value( + &mut modal.options.style, + DjMixStyle::SmartDj, + "Smart DJ", + ); + }); + }); + ui.small(match modal.options.style { + DjMixStyle::Crossfade => "Simple equal-power overlap. No tempo change, loop roll, echo, or filter performance.", + DjMixStyle::SmartDj => "Human-style transition planner. Uses Essentia for beat/key analysis, Rubber Band R3 for studio-quality tempo matching, and Demucs stems when those tools are installed; otherwise it falls back safely.", + }); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.add_enabled_ui(!running, |ui| { + ui.checkbox(&mut modal.options.smart_order, "Smart BPM order"); + ui.checkbox(&mut modal.options.normalize_loudness, "Loudness leveling"); + ui.checkbox(&mut modal.options.bass_swap, "Bass swap"); + ui.checkbox(&mut modal.options.professional_tools, "Professional tools"); + ui.checkbox(&mut modal.options.stem_separation, "Stem-aware mixing"); + }); + }); + ui.horizontal_wrapped(|ui| { + ui.label("Transition:"); + for bars in [8, 16, 32] { + ui.add_enabled_ui(!running, |ui| { + ui.selectable_value( + &mut modal.options.transition_bars, + bars, + format!("{bars} bars"), + ); + }); + } + ui.separator(); + ui.label("MP3:"); + for bitrate in [192, 256, 320] { + ui.add_enabled_ui(!running, |ui| { + ui.selectable_value( + &mut modal.options.bitrate_kbps, + bitrate, + format!("{bitrate} kbps"), + ); + }); + } + }); + if modal.options.style == DjMixStyle::SmartDj { + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.label("Bridge:"); + ui.add_enabled_ui(!running, |ui| { + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::Auto, "Auto human"); + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::DrumSwap, "Drum swap"); + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::HarmonicBridge, "Harmonic bridge"); + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::EchoDrop, "Echo drop"); + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::StemMashup, "Stem mashup"); + ui.selectable_value(&mut modal.options.bridge_mode, DjBridgeMode::Custom, "Custom audio"); + }); + }); + ui.small("Auto chooses a phrase-level recipe. With Demucs, drums, bass, vocals, and accompaniment are handed over separately instead of fading two complete songs together."); + ui.horizontal_wrapped(|ui| { + ui.small(&modal.professional_tool_status); + if ui.add_enabled(!running, egui::Button::new("Refresh tools")).clicked() { + modal.professional_tool_status = dj_mix::professional_tool_status_summary(); + } + }); + if modal.options.bridge_mode == DjBridgeMode::Custom { + ui.horizontal_wrapped(|ui| { + let label = modal.custom_bridge_path.as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "No custom bridge selected".to_owned()); + ui.label(ellipsize_chars(&label, 54)); + if ui.add_enabled(!running, egui::Button::new("Choose MP3/audio...")).clicked() { + choose_bridge_requested = true; + } + if modal.custom_bridge_path.is_some() + && ui.add_enabled(!running, egui::Button::new("Clear")).clicked() + { + clear_bridge_requested = true; + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Loop from:"); + ui.add_enabled(!running, egui::DragValue::new(&mut modal.options.bridge_start_seconds).range(0.0..=86_400.0).speed(0.5).suffix(" s")); + ui.label("Loop length:"); + ui.add_enabled(!running, egui::DragValue::new(&mut modal.options.bridge_loop_seconds).range(0.25..=60.0).speed(0.25).suffix(" s")); + }); + } + ui.horizontal_wrapped(|ui| { + ui.label("Bridge level:"); + ui.add_enabled(!running, egui::Slider::new(&mut modal.options.bridge_level, 0.15..=1.0).show_value(true)); + }); + } + ui.horizontal_wrapped(|ui| { + ui.label("Target length:"); + ui.add_enabled( + !running, + egui::DragValue::new(&mut modal.options.target_minutes) + .range(1.0..=180.0) + .speed(1.0) + .suffix(" min"), + ); + ui.small("Auto highlight sections are shortened to approach target."); + }); + ui.small("Export runs on a background worker. External tools are optional and never bundled; missing tools trigger deterministic built-in fallbacks."); + }); + + ui.add_space(8.0); + Self::render_modal_section(ui, |ui| { + let show_progress = running + || modal.completed + || modal.stage != "Ready"; + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 48.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + if show_progress { + ui.horizontal(|ui| { + if running { + ui.spinner(); + } + ui.label(&modal.stage); + if let Some(started_at) = modal.started_at { + let elapsed = started_at.elapsed().as_secs(); + ui.small(format!("{}:{:02}", elapsed / 60, elapsed % 60)); + } + }); + ui.add( + egui::ProgressBar::new(modal.progress) + .animate(running) + .show_percentage(), + ); + if running { + ui.small("Background worker active — playback and library remain usable."); + } + } + }, + ); + ui.small("Choose target length and section mode per track, then export MP3. The .dj-plan.json report records tool backends and transition recipes."); + if let Some(summary) = &modal.diagnostics_summary { + ui.small(summary); + } + if modal.tracks.len() < 2 { + ui.small("Choose at least two available tracks."); + } + ui.horizontal_wrapped(|ui| { + if running { + if ui.button(ui_icons::label(Icon::X, "Cancel export")).clicked() { + cancel_requested = true; + } + } else if modal.completed { + if let Some(path) = modal.output_path.clone() { + if ui.button(ui_icons::label(Icon::Play, "Play")).clicked() { + play_path = Some(path.clone()); + } + if ui.button(ui_icons::label(Icon::FolderOpen, "Show MP3")).clicked() { + reveal_path = Some(path); + } + } + if let Some(path) = modal.report_path.clone() { + if ui.button("Show diagnostics").clicked() { + reveal_path = Some(path); + } + } + if ui + .add_enabled( + modal.tracks.len() >= 2, + egui::Button::new(ui_icons::label( + Icon::Music, + "Export another...", + )), + ) + .clicked() + { + start_requested = true; + } + if ui.button("Close").clicked() { + close_requested = true; + } + } else { + if ui + .add_enabled( + modal.tracks.len() >= 2, + egui::Button::new(ui_icons::label(Icon::Music, "Export DJ mix...")), + ) + .clicked() + { + start_requested = true; + } + if ui.button("Close").clicked() { + close_requested = true; + } + } + }); + }); + + ui.add_space(8.0); + Self::render_modal_section(ui, |ui| { + ui.horizontal(|ui| { + ui.heading(format!("Tracks ({})", modal.tracks.len())); + if modal.options.smart_order { + ui.small("Export order optimized after BPM analysis"); + } else { + ui.small("Manual order"); + } + }); + egui::ScrollArea::vertical() + .id_salt("dj_mix_tracks") + .max_height(track_scroll_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + let mut move_action = None; + let mut remove_index = None; + let track_count = modal.tracks.len(); + for index in 0..track_count { + let title = modal.tracks[index].title.clone(); + let path = modal.tracks[index].path.clone(); + let previous_section_mode = modal.tracks[index].section_mode; + let mut section_mode = previous_section_mode; + let mut favorite_start = modal.tracks[index].favorite_start_seconds; + let mut favorite_end = modal.tracks[index].favorite_end_seconds; + let (waveform, known_duration) = + self.dj_preview_waveform_for_path(&path); + let duration_seconds = known_duration + .unwrap_or_else(|| favorite_end.max(60.0)) + .max(0.25) + .round() + .max(1.0); + let preview_is_active = self + .active_track_path + .as_ref() + .map(|active| same_path(active, &path)) + .unwrap_or(false); + let preview_is_playing = preview_is_active + && self + .player + .as_ref() + .map(AudioPlayer::is_playing) + .unwrap_or(false); + let preview_is_paused = preview_is_active + && self + .player + .as_ref() + .map(AudioPlayer::is_paused) + .unwrap_or(false); + let playhead_seconds = preview_is_active + .then(|| self.displayed_playback_position_seconds()); + ui.horizontal(|ui| { + ui.label(format!("{}.", index + 1)); + let track_content_width = + (ui.available_width() - 104.0).max(220.0); + ui.vertical(|ui| { + ui.set_max_width(track_content_width); + ui.label(ellipsize_chars(&title, 72)); + ui.small(ellipsize_chars(&path.display().to_string(), 96)); + ui.horizontal_wrapped(|ui| { + ui.add_enabled_ui(!running, |ui| { + ui.selectable_value( + &mut section_mode, + DjTrackSectionMode::AutoHighlight, + "Auto highlight", + ); + ui.selectable_value( + &mut section_mode, + DjTrackSectionMode::FullTrack, + "Full track", + ); + ui.selectable_value( + &mut section_mode, + DjTrackSectionMode::FavoriteRange, + "Favorite range", + ); + }); + }); + if section_mode == DjTrackSectionMode::FavoriteRange { + let mut favorite_start_changed = false; + let mut favorite_end_changed = false; + let waveform_id = egui::Id::new(( + "dj_favorite_range_waveform", + index, + path_key(&path), + )); + let waveform_action = draw_dj_favorite_range_waveform( + ui, + waveform_id, + &waveform, + duration_seconds, + &mut favorite_start, + &mut favorite_end, + playhead_seconds, + !running && known_duration.is_some(), + ); + favorite_start_changed |= waveform_action.start_changed; + favorite_end_changed |= waveform_action.end_changed; + if let Some(seek_seconds) = waveform_action.seek_seconds { + let stop_seconds = if seek_seconds < favorite_end { + favorite_end + } else { + duration_seconds + }; + if preview_is_active { + preview_seek_request = Some((seek_seconds, stop_seconds)); + } else { + preview_play_request = Some(( + path.clone(), + seek_seconds, + stop_seconds, + )); + } + } + ui.horizontal_wrapped(|ui| { + let preview_label = if preview_is_playing { + ui_icons::label(Icon::Pause, "Pause preview") + } else if preview_is_paused { + ui_icons::label(Icon::Play, "Resume preview") + } else { + ui_icons::label(Icon::Play, "Play range") + }; + if ui + .add_enabled(!running, egui::Button::new(preview_label)) + .clicked() + { + if preview_is_playing || preview_is_paused { + preview_pause_resume_requested = true; + } else { + preview_play_request = Some(( + path.clone(), + favorite_start, + favorite_end, + )); + } + } + if preview_is_active { + if ui + .add_enabled( + !running, + egui::Button::new("Start = playhead"), + ) + .clicked() + { + let playhead = snap_favorite_range_second( + self.displayed_playback_position_seconds(), + duration_seconds, + ); + favorite_start = playhead.min( + (favorite_end - 1.0).max(0.0), + ); + favorite_start_changed = true; + } + if ui + .add_enabled( + !running, + egui::Button::new("End = playhead"), + ) + .clicked() + { + let playhead = snap_favorite_range_second( + self.displayed_playback_position_seconds(), + duration_seconds, + ); + favorite_end = playhead.max( + (favorite_start + 1.0) + .min(duration_seconds), + ); + favorite_end_changed = true; + } + } + ui.small(format!( + "{} – {} · {}", + format_duration(favorite_start), + format_duration(favorite_end), + format_duration( + (favorite_end - favorite_start).max(0.0), + ), + )); + }); + ui.horizontal_wrapped(|ui| { + ui.label("Start:"); + let start_response = ui.add_enabled( + !running, + egui::DragValue::new(&mut favorite_start) + .range(0.0..=duration_seconds) + .speed(1.0) + .fixed_decimals(0) + .suffix(" s"), + ); + favorite_start_changed |= start_response.changed(); + ui.label("End:"); + let end_response = ui.add_enabled( + !running, + egui::DragValue::new(&mut favorite_end) + .range(0.0..=duration_seconds) + .speed(1.0) + .fixed_decimals(0) + .suffix(" s"), + ); + favorite_end_changed |= end_response.changed(); + }); + favorite_start = snap_favorite_range_second( + favorite_start, + duration_seconds, + ) + .clamp(0.0, (duration_seconds - 1.0).max(0.0)); + favorite_end = snap_favorite_range_second( + favorite_end, + duration_seconds, + ) + .clamp( + (favorite_start + 1.0).min(duration_seconds), + duration_seconds, + ); + if favorite_start_changed { + if preview_is_active { + preview_seek_request = Some(( + favorite_start, + favorite_end, + )); + } else { + preview_play_request = Some(( + path.clone(), + favorite_start, + favorite_end, + )); + } + } else if favorite_end_changed && preview_is_active { + preview_stop_update_request = Some(( + path.clone(), + favorite_end, + )); + } + if waveform.is_empty() { + ui.small( + "Play preview first. Background decoding loads the real duration and waveform without blocking the DJ window.", + ); + } else { + ui.small( + "Left click jumps playback. Right click moves the start and seeks there. Drag either blue edge. Values use whole seconds.", + ); + } + if preview_is_playing { + ui.ctx().request_repaint_after(Duration::from_millis(50)); + } + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled(!running && track_count > 2, egui::Button::new(ui_icons::icon(Icon::X))) + .on_hover_text("Remove from mix") + .clicked() + { + remove_index = Some(index); + } + if ui + .add_enabled(!running && index + 1 < track_count, egui::Button::new(ui_icons::icon(Icon::ArrowDown))) + .on_hover_text("Move down") + .clicked() + { + move_action = Some((index, index + 1)); + } + if ui + .add_enabled(!running && index > 0, egui::Button::new(ui_icons::icon(Icon::ArrowUp))) + .on_hover_text("Move up") + .clicked() + { + move_action = Some((index, index - 1)); + } + }); + }); + if previous_section_mode + == DjTrackSectionMode::FavoriteRange + && section_mode + != DjTrackSectionMode::FavoriteRange + && modal + .preview_track_path + .as_ref() + .map(|preview| same_path(preview, &path)) + .unwrap_or(false) + { + stop_preview_requested = true; + } + favorite_start = snap_favorite_range_second( + favorite_start, + duration_seconds, + ) + .clamp( + 0.0, + (duration_seconds - 1.0).max(0.0), + ); + favorite_end = snap_favorite_range_second( + favorite_end, + duration_seconds, + ) + .clamp( + (favorite_start + 1.0) + .min(duration_seconds), + duration_seconds, + ); + modal.tracks[index].section_mode = section_mode; + modal.tracks[index].favorite_start_seconds = favorite_start.max(0.0); + modal.tracks[index].favorite_end_seconds = favorite_end.max(0.0); + if index + 1 < track_count { + ui.separator(); + } + } + if let Some((from, to)) = move_action { + modal.tracks.swap(from, to); + modal.options.smart_order = false; + } + if let Some(index) = remove_index { + if modal + .tracks + .get(index) + .zip(modal.preview_track_path.as_ref()) + .map(|(track, preview)| { + same_path(&track.path, preview) + }) + .unwrap_or(false) + { + stop_preview_requested = true; + } + modal.tracks.remove(index); + } + }); + }); + }); + }); + }); + }); + self.render_modal_info_footer_fixed(context, "dj_mix_modal_info_footer", screen_rect); + + if clear_bridge_requested { + modal.custom_bridge_path = None; + } + if choose_bridge_requested { + if let Some(path) = FileDialog::new() + .add_filter("Audio bridge", &["mp3", "wav", "flac", "ogg"]) + .pick_file() + { + modal.custom_bridge_path = Some(path); + } + } + self.dj_mix_modal = Some(modal); + if stop_preview_requested { + self.stop_dj_preview_playback(); + } + if let Some((path, stop_seconds)) = preview_stop_update_request { + if let Some(modal) = self.dj_mix_modal.as_mut() { + if modal + .preview_track_path + .as_ref() + .map(|preview| same_path(preview, &path)) + .unwrap_or(false) + { + modal.preview_stop_seconds = Some(stop_seconds); + } + } + } + if preview_pause_resume_requested { + self.pause_or_resume(); + } else if let Some((seconds, stop_seconds)) = preview_seek_request { + let active_preview_path = self.active_track_path.clone(); + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.preview_track_path = active_preview_path; + modal.preview_stop_seconds = Some(stop_seconds); + } + self.seek_current(seconds); + } else if let Some((path, seconds, stop_seconds)) = preview_play_request { + let track_index = self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &path)) + }); + if let Some(modal) = self.dj_mix_modal.as_mut() { + modal.preview_track_path = Some(path.clone()); + modal.preview_stop_seconds = Some(stop_seconds); + } + self.play_path_with_crossfade(path, track_index, seconds, 0.0); + } + if cancel_requested { + self.cancel_dj_mix_export(); + } + if let Some(path) = play_path { + self.stop_dj_preview_playback(); + self.dj_mix_modal = None; + self.open_audio_files_in_temporary_playlist(vec![path], true); + return; + } + if let Some(path) = reveal_path { + if let Err(error) = reveal_in_file_manager(&path) { + self.error_message = Some(error.to_string()); + } + } + if start_requested { + self.start_dj_mix_export(); + } + if close_requested && !self.dj_mix_is_running() { + self.stop_dj_preview_playback(); + self.dj_mix_modal = None; + } + } +} diff --git a/src/app/external_files.rs b/src/app/external_files.rs new file mode 100644 index 0000000..7a680b4 --- /dev/null +++ b/src/app/external_files.rs @@ -0,0 +1,120 @@ +use crate::*; + +const OPEN_REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(500); + +impl AudioOrbitApp { + fn temporary_playlist_index(&mut self) -> usize { + if let Some(index) = self + .state + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary) + { + return index; + } + + self.state.playlists.push(Playlist::temporary()); + self.state.playlists.len() - 1 + } + + pub(crate) fn open_audio_files_in_temporary_playlist( + &mut self, + paths: Vec, + autoplay: bool, + ) { + let mut valid_paths = Vec::new(); + for path in paths { + if !path.is_file() || !is_supported_audio_file(&path) { + continue; + } + if !valid_paths + .iter() + .any(|existing: &PathBuf| same_path(existing, &path)) + { + valid_paths.push(path); + } + } + + let Some(first_path) = valid_paths.first().cloned() else { + self.error_message = Some("No supported audio files were opened.".to_owned()); + return; + }; + + if autoplay { + self.stop(); + } + + let playlist_index = self.temporary_playlist_index(); + if let Some(playlist) = self.state.playlists.get_mut(playlist_index) { + playlist.add_temporary_files(valid_paths.clone()); + } + + let track_index = self + .state + .playlists + .get(playlist_index) + .and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &first_path)) + }); + + self.active_tab = MainContentTab::Music; + self.select_playlist(playlist_index); + self.selected_track_index = track_index; + self.scroll_to_track_path_requested = Some(first_path.clone()); + self.status_message = if valid_paths.len() == 1 { + format!( + "Opened {} in temporary playback.", + display_file_name(&first_path) + ) + } else { + format!("Opened {} files in temporary playback.", valid_paths.len()) + }; + self.error_message = None; + + if autoplay { + self.play_path(first_path, track_index, 0.0); + } + } + + pub(crate) fn process_external_open_requests(&mut self, context: &egui::Context) { + if self.last_external_open_request_poll.elapsed() < OPEN_REQUEST_POLL_INTERVAL { + return; + } + self.last_external_open_request_poll = Instant::now(); + + let Some(request_dir) = single_instance::open_request_dir() else { + return; + }; + let Ok(entries) = fs::read_dir(&request_dir) else { + return; + }; + + let mut request_files = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension().and_then(|extension| extension.to_str()) == Some("json") + }) + .collect::>(); + request_files.sort(); + + let mut paths = Vec::new(); + for request_file in request_files { + let request_paths = fs::read_to_string(&request_file) + .ok() + .and_then(|contents| serde_json::from_str::>(&contents).ok()) + .unwrap_or_default(); + let _ = fs::remove_file(&request_file); + paths.extend(request_paths); + } + + if !paths.is_empty() { + self.open_audio_files_in_temporary_playlist(paths, true); + context.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + context.send_viewport_cmd(egui::ViewportCommand::Focus); + } + } +} diff --git a/src/app/input.rs b/src/app/input.rs index 2d49c88..54ec801 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -6,6 +6,7 @@ impl AudioOrbitApp { || self.show_radio_add_modal || self.show_new_playlist_modal || self.pending_track_delete_confirmation.is_some() + || self.dj_mix_modal.is_some() || self.details_modal.is_some(); let panel_shortcut = context.input(|input| { if input.key_pressed(egui::Key::F1) { @@ -37,7 +38,19 @@ impl AudioOrbitApp { return; } - let (space, enter, stop, next, previous, seek_forward, seek_backward, search, player_only, library, profiles) = context.input(|input| { + let ( + space, + enter, + stop, + next, + previous, + seek_forward, + seek_backward, + search, + player_only, + library, + profiles, + ) = context.input(|input| { ( input.key_pressed(egui::Key::Space), input.key_pressed(egui::Key::Enter), diff --git a/src/app/library_backup.rs b/src/app/library_backup.rs index 3ae4288..b5d0e17 100644 --- a/src/app/library_backup.rs +++ b/src/app/library_backup.rs @@ -16,7 +16,13 @@ impl AppliedLibrarySyncStats { impl AudioOrbitApp { pub(crate) fn add_audio_files(&mut self) { let Some(files) = FileDialog::new() - .add_filter("Audio files", &["mp3", "wav", "flac", "ogg", "opus", "m4a", "mp4", "aac", "aiff", "aif", "ape", "wv"]) + .add_filter( + "Audio files", + &[ + "mp3", "wav", "flac", "ogg", "opus", "m4a", "mp4", "aac", "aiff", "aif", "ape", + "wv", + ], + ) .add_filter("All files", &["*"]) .pick_files() else { @@ -27,14 +33,19 @@ impl AudioOrbitApp { return; }; if !playlist.accepts_manual_tracks() { - self.error_message = Some("Folder playlists are scanner-owned. Add files to a manual playlist or Favorites instead.".to_owned()); + self.error_message = Some( + "This playlist is read-only. Add files to a manual playlist or Favorites instead." + .to_owned(), + ); return; } - let Some((added_paths, playlist_name, playlist_kind)) = self.current_playlist_mut().map(|playlist| { - let added_paths = playlist.add_files(files); - (added_paths, playlist.name.clone(), playlist.kind.clone()) - }) else { + let Some((added_paths, playlist_name, playlist_kind)) = + self.current_playlist_mut().map(|playlist| { + let added_paths = playlist.add_files(files); + (added_paths, playlist.name.clone(), playlist.kind.clone()) + }) + else { return; }; @@ -46,13 +57,21 @@ impl AudioOrbitApp { }; self.clear_multi_track_selection(); self.selected_track_index = selected_path.and_then(|path| { - self.current_playlist() - .and_then(|playlist| playlist.tracks.iter().position(|track| same_path(&track.path, path))) + self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, path)) + }) }); if self.active_playlist_index == Some(self.state.selected_playlist_index) { self.active_track_index = self.active_track_path.as_ref().and_then(|active_path| { - self.current_playlist() - .and_then(|playlist| playlist.tracks.iter().position(|track| same_path(&track.path, active_path))) + self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, active_path)) + }) }); } self.ensure_selected_track_visible(); @@ -75,7 +94,9 @@ impl AudioOrbitApp { return; }; - if self.pending_playlist_name.trim().is_empty() || self.pending_playlist_name == "Local music" { + if self.pending_playlist_name.trim().is_empty() + || self.pending_playlist_name == "Local music" + { self.pending_playlist_name = folder .file_name() .and_then(|name| name.to_str()) @@ -152,7 +173,8 @@ impl AudioOrbitApp { Err(mpsc::TryRecvError::Empty) => return, Err(mpsc::TryRecvError::Disconnected) => { self.pending_folder_scan_receiver = None; - self.error_message = Some("Folder scan stopped before returning a result.".to_owned()); + self.error_message = + Some("Folder scan stopped before returning a result.".to_owned()); return; } }; @@ -167,7 +189,11 @@ impl AudioOrbitApp { } }; - let PendingFolderScanKind::Import { name, folder, depth } = result.kind; + let PendingFolderScanKind::Import { + name, + folder, + depth, + } = result.kind; if result.files.is_empty() { self.error_message = Some(format!( "No supported audio files were found under {}.", @@ -179,8 +205,21 @@ impl AudioOrbitApp { let track_count = result.files.len(); let playlist = Playlist::from_folder(name.clone(), folder.clone(), depth, result.files); - self.state.playlists.push(playlist); - self.state.selected_playlist_index = self.state.playlists.len() - 1; + let insert_index = self + .state + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(self.state.playlists.len()); + self.state.playlists.insert(insert_index, playlist); + if self + .active_playlist_index + .map(|index| index >= insert_index) + .unwrap_or(false) + { + self.active_playlist_index = self.active_playlist_index.map(|index| index + 1); + } + self.state.selected_playlist_index = insert_index; self.restore_repeat_selection_for_current_playlist(); self.clear_multi_track_selection(); self.selected_track_index = self.eligible_track_indexes().first().copied(); @@ -202,7 +241,10 @@ impl AudioOrbitApp { || self.pending_track_file_operation_receiver.is_some() { if trigger == LibrarySyncTrigger::Manual { - self.error_message = Some("Wait for current library or track file operation to finish before syncing.".to_owned()); + self.error_message = Some( + "Wait for current library or track file operation to finish before syncing." + .to_owned(), + ); } return false; } @@ -288,7 +330,9 @@ impl AudioOrbitApp { &mut self, changed_paths: Vec, ) -> bool { - if self.pending_folder_scan_receiver.is_some() || self.pending_library_sync_receiver.is_some() { + if self.pending_folder_scan_receiver.is_some() + || self.pending_library_sync_receiver.is_some() + { return false; } @@ -523,7 +567,8 @@ impl AudioOrbitApp { Err(mpsc::TryRecvError::Empty) => return, Err(mpsc::TryRecvError::Disconnected) => { self.pending_library_sync_receiver = None; - self.error_message = Some("Playlist sync stopped before returning a result.".to_owned()); + self.error_message = + Some("Playlist sync stopped before returning a result.".to_owned()); return; } }; @@ -545,10 +590,9 @@ impl AudioOrbitApp { for folder_result in folder_results { match folder_result.outcome { Ok(FolderLibrarySyncOutcome::Scanned { files }) => { - let Some(resolved_index) = resolve_folder_sync_target( - &self.state.playlists, - &folder_result.target, - ) else { + let Some(resolved_index) = + resolve_folder_sync_target(&self.state.playlists, &folder_result.target) + else { continue; }; let Some(playlist) = self.state.playlists.get_mut(resolved_index) else { @@ -564,10 +608,9 @@ impl AudioOrbitApp { missing_roots, errors: incremental_errors, }) => { - let Some(resolved_index) = resolve_folder_sync_target( - &self.state.playlists, - &folder_result.target, - ) else { + let Some(resolved_index) = + resolve_folder_sync_target(&self.state.playlists, &folder_result.target) + else { continue; }; let Some(playlist) = self.state.playlists.get_mut(resolved_index) else { @@ -623,7 +666,10 @@ impl AudioOrbitApp { if errors.len() == 1 { format!("Folder could not be synced: {error}") } else { - format!("{} folders could not be synced. First error: {error}", errors.len()) + format!( + "{} folders could not be synced. First error: {error}", + errors.len() + ) } }); } @@ -661,9 +707,12 @@ impl AudioOrbitApp { fn remap_track_indexes_after_library_change(&mut self, selected_path: Option) { self.clear_multi_track_selection(); if let Some(path) = selected_path { - self.selected_track_index = self - .current_playlist() - .and_then(|playlist| playlist.tracks.iter().position(|track| same_path(&track.path, &path))); + self.selected_track_index = self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &path)) + }); } else if self .selected_track_index .map(|index| { @@ -680,12 +729,15 @@ impl AudioOrbitApp { .active_playlist_index .zip(self.active_track_path.as_ref()) .and_then(|(playlist_index, active_path)| { - self.state.playlists.get(playlist_index).and_then(|playlist| { - playlist - .tracks - .iter() - .position(|track| same_path(&track.path, active_path)) - }) + self.state + .playlists + .get(playlist_index) + .and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, active_path)) + }) }); self.restore_repeat_selection_for_current_playlist(); self.ensure_selected_track_visible(); @@ -805,7 +857,10 @@ fn resolve_folder_sync_target( return Some(target.playlist_index); } - if let Some(index) = playlists.iter().position(|playlist| matches_target(playlist)) { + if let Some(index) = playlists + .iter() + .position(|playlist| matches_target(playlist)) + { return Some(index); } diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 625e0a6..3238bdc 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -2,6 +2,9 @@ use crate::*; impl Drop for AudioOrbitApp { fn drop(&mut self) { + if let Some(cancel) = &self.dj_mix_cancel_flag { + cancel.store(true, std::sync::atomic::Ordering::Relaxed); + } self.persist_playback_session(); self.persist_repeat_selection_for_current_playlist(); if let Some(player) = &mut self.player { @@ -15,7 +18,6 @@ impl Drop for AudioOrbitApp { } } - impl AudioOrbitApp { fn next_repaint_interval(&self, context: &egui::Context) -> Duration { let has_live_input = context.input(|input| { @@ -34,6 +36,10 @@ impl AudioOrbitApp { return Duration::from_millis(500); } + if self.dj_mix_is_running() { + return Duration::from_millis(80); + } + if self.waveform_loading_animation_is_active() { return WAVEFORM_LOADING_REPAINT_INTERVAL; } @@ -42,7 +48,11 @@ impl AudioOrbitApp { return RADIO_REPAINT_INTERVAL; } - if self.player.as_ref().map(AudioPlayer::is_playing).unwrap_or(false) + if self + .player + .as_ref() + .map(AudioPlayer::is_playing) + .unwrap_or(false) || self.pending_track_switch.is_some() { return PLAYBACK_REPAINT_INTERVAL; @@ -79,12 +89,16 @@ impl AudioOrbitApp { || self.pending_folder_scan_receiver.is_some() || self.pending_library_sync_receiver.is_some() || self.pending_track_file_operation_receiver.is_some() + || self.dj_mix_event_receiver.is_some() || self.pending_folder_watch_sync_at.is_some() || self.update_check_receiver.is_some() || self.update_install_receiver.is_some() || self.radio_title_receiver.is_some() || self.pending_profile_apply_at.is_some() - || self.profile_apply_applied_until.map(|until| until > Instant::now()).unwrap_or(false) + || self + .profile_apply_applied_until + .map(|until| until > Instant::now()) + .unwrap_or(false) || self.detected_output_change.is_some() || self.focus_track_search || self.focus_radio_search @@ -104,6 +118,7 @@ impl eframe::App for AudioOrbitApp { self.update_dev_metrics(context, repaint_interval); self.remember_window_geometry(context); + self.process_external_open_requests(context); self.process_media_key_events(); self.process_update_events(); self.maybe_start_auto_update_check(); @@ -117,6 +132,7 @@ impl eframe::App for AudioOrbitApp { self.process_folder_scan_events(); self.process_library_sync_events(); self.process_track_file_operation_events(); + self.process_dj_mix_events(); self.maybe_start_auto_library_sync(); self.process_pending_fast_seek(); self.process_pending_seek_prepare(); @@ -129,9 +145,10 @@ impl eframe::App for AudioOrbitApp { context.copy_text(text); } - let now_playing_response = egui::TopBottomPanel::top("now_playing_panel").show(context, |ui| { - self.render_now_playing_panel(ui); - }); + let now_playing_response = + egui::TopBottomPanel::top("now_playing_panel").show(context, |ui| { + self.render_now_playing_panel(ui); + }); self.handle_top_panel_volume_wheel(&now_playing_response.response, context); if !self.player_only_mode && self.show_library_panel { @@ -202,6 +219,10 @@ impl eframe::App for AudioOrbitApp { self.render_track_delete_confirmation_modal(context); } + if self.dj_mix_modal.is_some() { + self.render_dj_mix_modal(context); + } + if self.details_modal.is_some() { self.render_details_modal(context); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 78c298e..682e667 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,9 +4,11 @@ //! provide the `AudioOrbitApp` implementation in focused units so playback, //! playlist, radio, updater, modal, and UI behavior can evolve independently. +mod core; #[cfg(debug_assertions)] pub(crate) mod dev_metrics; -mod core; +mod dj_mix; +mod external_files; mod input; mod library_backup; mod lifecycle; diff --git a/src/app/ordering.rs b/src/app/ordering.rs index 5eac8bd..b1dfa49 100644 --- a/src/app/ordering.rs +++ b/src/app/ordering.rs @@ -2,10 +2,31 @@ use crate::*; impl AudioOrbitApp { pub(crate) fn add_playlist(&mut self) { - let number = self.state.playlists.len() + 1; + let number = self + .state + .playlists + .iter() + .filter(|playlist| playlist.kind != PlaylistKind::Temporary) + .count() + + 1; self.persist_repeat_selection_for_current_playlist(); - self.state.playlists.push(Playlist::new(format!("Playlist {number}"))); - self.state.selected_playlist_index = self.state.playlists.len() - 1; + let insert_index = self + .state + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(self.state.playlists.len()); + self.state + .playlists + .insert(insert_index, Playlist::new(format!("Playlist {number}"))); + if self + .active_playlist_index + .map(|index| index >= insert_index) + .unwrap_or(false) + { + self.active_playlist_index = self.active_playlist_index.map(|index| index + 1); + } + self.state.selected_playlist_index = insert_index; self.restore_repeat_selection_for_current_playlist(); self.clear_multi_track_selection(); self.selected_track_index = None; @@ -19,7 +40,7 @@ impl AudioOrbitApp { }; if !playlist.kind.can_delete() { - self.error_message = Some("Favorites is built-in and cannot be deleted.".to_owned()); + self.error_message = Some("Built-in playlists cannot be deleted.".to_owned()); return; } @@ -39,7 +60,9 @@ impl AudioOrbitApp { self.active_playlist_index = Some(active_playlist_index - 1); } } - self.state.selected_playlist_index = removed_index.saturating_sub(1).min(self.state.playlists.len() - 1); + self.state.selected_playlist_index = removed_index + .saturating_sub(1) + .min(self.state.playlists.len() - 1); self.restore_repeat_selection_for_current_playlist(); self.clear_multi_track_selection(); self.selected_track_index = self.eligible_track_indexes().first().copied(); @@ -51,13 +74,22 @@ impl AudioOrbitApp { self.save_state_silently(); } pub(crate) fn move_playlist(&mut self, from: usize, delta: isize) { - if from >= self.state.playlists.len() { + if from >= self.state.playlists.len() + || self.state.playlists[from].kind == PlaylistKind::Temporary + { return; } + let last_movable_index = self + .state + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(self.state.playlists.len()) + .saturating_sub(1); let to = if delta < 0 { from.saturating_sub(1) } else { - (from + 1).min(self.state.playlists.len() - 1) + (from + 1).min(last_movable_index) }; if from == to { return; @@ -122,11 +154,18 @@ impl AudioOrbitApp { if playlist_index < self.state.playlists.len() { self.state.playlists[playlist_index] = playlist; self.state.selected_playlist_index = playlist_index; - self.selected_track_index = selected_track_index - .filter(|index| self.current_playlist().map(|playlist| *index < playlist.tracks.len()).unwrap_or(false)); + self.selected_track_index = selected_track_index.filter(|index| { + self.current_playlist() + .map(|playlist| *index < playlist.tracks.len()) + .unwrap_or(false) + }); self.selected_track_indexes = selected_track_indexes .into_iter() - .filter(|index| self.current_playlist().map(|playlist| *index < playlist.tracks.len()).unwrap_or(false)) + .filter(|index| { + self.current_playlist() + .map(|playlist| *index < playlist.tracks.len()) + .unwrap_or(false) + }) .collect(); self.clear_multi_track_selection(); self.active_playlist_index = active_playlist_index; @@ -143,10 +182,10 @@ impl AudioOrbitApp { radio_selection_was_user_set, } => { self.state.radio_stations = stations; - self.state.selected_radio_index = selected_radio_index - .filter(|index| *index < self.state.radio_stations.len()); - self.active_radio_index = active_radio_index - .filter(|index| *index < self.state.radio_stations.len()); + self.state.selected_radio_index = + selected_radio_index.filter(|index| *index < self.state.radio_stations.len()); + self.active_radio_index = + active_radio_index.filter(|index| *index < self.state.radio_stations.len()); self.radio_selection_was_user_set = radio_selection_was_user_set; self.status_message = "Undid radio order change.".to_owned(); } @@ -170,13 +209,15 @@ impl AudioOrbitApp { let Some(playlist) = self.current_playlist() else { return false; }; - if index >= playlist.tracks.len() { + if index >= playlist.tracks.len() || playlist.kind == PlaylistKind::Temporary { return false; } let target = if delta < 0 { index.checked_sub(1) } else { - index.checked_add(1).filter(|target| *target < playlist.tracks.len()) + index + .checked_add(1) + .filter(|target| *target < playlist.tracks.len()) }; let Some(target) = target else { return false; @@ -187,28 +228,45 @@ impl AudioOrbitApp { pub(crate) fn restore_track_selection_after_reorder(&mut self, selected_path: Option) { self.clear_multi_track_selection(); if let Some(selected_path) = selected_path { - if let Some(index) = self - .current_playlist() - .and_then(|playlist| playlist.tracks.iter().position(|track| same_path(&track.path, &selected_path))) - { + if let Some(index) = self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &selected_path)) + }) { self.selected_track_index = Some(index); } } - if let (Some(active_playlist_index), Some(active_path)) = (self.active_playlist_index, self.active_track_path.clone()) { + if let (Some(active_playlist_index), Some(active_path)) = + (self.active_playlist_index, self.active_track_path.clone()) + { if active_playlist_index == self.state.selected_playlist_index { - self.active_track_index = self - .current_playlist() - .and_then(|playlist| playlist.tracks.iter().position(|track| same_path(&track.path, &active_path))); + self.active_track_index = self.current_playlist().and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &active_path)) + }); } } self.restore_repeat_selection_for_current_playlist(); } pub(crate) fn sort_current_playlist_by_name(&mut self, ascending: bool) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + return; + } self.persist_repeat_selection_for_current_playlist(); let selected_path = self.selected_track_path(); - let should_sort = self.current_playlist().map(|playlist| playlist.tracks.len() > 1).unwrap_or(false); + let should_sort = self + .current_playlist() + .map(|playlist| playlist.tracks.len() > 1) + .unwrap_or(false); if should_sort { self.push_playlist_order_undo(); } @@ -218,7 +276,11 @@ impl AudioOrbitApp { .cmp(&naturalish_key(&right.group)) .then_with(|| naturalish_key(&left.title).cmp(&naturalish_key(&right.title))) .then_with(|| left.path.cmp(&right.path)); - if ascending { ordering } else { ordering.reverse() } + if ascending { + ordering + } else { + ordering.reverse() + } }); } self.restore_track_selection_after_reorder(selected_path); @@ -240,7 +302,10 @@ impl AudioOrbitApp { self.persist_repeat_selection_for_current_playlist(); let selected_path = self.selected_track_path(); - let should_sort = self.current_playlist().map(|playlist| playlist.tracks.len() > 1).unwrap_or(false); + let should_sort = self + .current_playlist() + .map(|playlist| playlist.tracks.len() > 1) + .unwrap_or(false); if should_sort { self.push_playlist_order_undo(); } @@ -258,7 +323,10 @@ impl AudioOrbitApp { } self.persist_repeat_selection_for_current_playlist(); let selected_path = self.selected_track_path(); - let Some(track_count) = self.current_playlist().map(|playlist| playlist.tracks.len()) else { + let Some(track_count) = self + .current_playlist() + .map(|playlist| playlist.tracks.len()) + else { return; }; let to = if delta < 0 { @@ -287,7 +355,9 @@ impl AudioOrbitApp { }; let track = playlist.tracks.remove(from); let insert_at = if from < to { to.saturating_sub(1) } else { to }; - playlist.tracks.insert(insert_at.min(playlist.tracks.len()), track); + playlist + .tracks + .insert(insert_at.min(playlist.tracks.len()), track); self.restore_track_selection_after_reorder(selected_path); self.status_message = "Moved track in playlist order.".to_owned(); self.save_state_silently(); @@ -301,7 +371,10 @@ impl AudioOrbitApp { let mut group_order: Vec = Vec::new(); for track in &playlist.tracks { - if group_order.last().map(|current| current != &track.group).unwrap_or(true) + if group_order + .last() + .map(|current| current != &track.group) + .unwrap_or(true) && !group_order.iter().any(|current| current == &track.group) { group_order.push(track.group.clone()); @@ -343,26 +416,41 @@ impl AudioOrbitApp { if self.state.radio_stations.len() > 1 { self.push_radio_order_undo(); } - let active_url = self - .active_radio_index - .and_then(|index| self.state.radio_stations.get(index).map(|station| station.url.clone())); - let selected_url = self - .state - .selected_radio_index - .and_then(|index| self.state.radio_stations.get(index).map(|station| station.url.clone())); + let active_url = self.active_radio_index.and_then(|index| { + self.state + .radio_stations + .get(index) + .map(|station| station.url.clone()) + }); + let selected_url = self.state.selected_radio_index.and_then(|index| { + self.state + .radio_stations + .get(index) + .map(|station| station.url.clone()) + }); self.state.radio_stations.sort_by(|left, right| { let ordering = naturalish_key(&left.name) .cmp(&naturalish_key(&right.name)) .then_with(|| left.url.cmp(&right.url)); - if ascending { ordering } else { ordering.reverse() } + if ascending { + ordering + } else { + ordering.reverse() + } }); self.active_radio_index = active_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.state.selected_radio_index = selected_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.radio_selection_was_user_set = self.state.selected_radio_index.is_some(); self.status_message = if ascending { @@ -376,13 +464,18 @@ impl AudioOrbitApp { if index >= self.state.radio_stations.len() { return; } - let active_url = self - .active_radio_index - .and_then(|active_index| self.state.radio_stations.get(active_index).map(|station| station.url.clone())); - let selected_url = self - .state - .selected_radio_index - .and_then(|selected_index| self.state.radio_stations.get(selected_index).map(|station| station.url.clone())); + let active_url = self.active_radio_index.and_then(|active_index| { + self.state + .radio_stations + .get(active_index) + .map(|station| station.url.clone()) + }); + let selected_url = self.state.selected_radio_index.and_then(|selected_index| { + self.state + .radio_stations + .get(selected_index) + .map(|station| station.url.clone()) + }); let to = if delta < 0 { index.saturating_sub(1) } else { @@ -394,10 +487,16 @@ impl AudioOrbitApp { self.push_radio_order_undo(); self.state.radio_stations.swap(index, to); self.active_radio_index = active_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.state.selected_radio_index = selected_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.radio_selection_was_user_set = self.state.selected_radio_index.is_some(); self.status_message = "Moved radio station.".to_owned(); @@ -408,21 +507,34 @@ impl AudioOrbitApp { return; } self.push_radio_order_undo(); - let active_url = self - .active_radio_index - .and_then(|active_index| self.state.radio_stations.get(active_index).map(|station| station.url.clone())); - let selected_url = self - .state - .selected_radio_index - .and_then(|selected_index| self.state.radio_stations.get(selected_index).map(|station| station.url.clone())); + let active_url = self.active_radio_index.and_then(|active_index| { + self.state + .radio_stations + .get(active_index) + .map(|station| station.url.clone()) + }); + let selected_url = self.state.selected_radio_index.and_then(|selected_index| { + self.state + .radio_stations + .get(selected_index) + .map(|station| station.url.clone()) + }); let station = self.state.radio_stations.remove(from); let insert_at = if from < to { to.saturating_sub(1) } else { to }; - self.state.radio_stations.insert(insert_at.min(self.state.radio_stations.len()), station); + self.state + .radio_stations + .insert(insert_at.min(self.state.radio_stations.len()), station); self.active_radio_index = active_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.state.selected_radio_index = selected_url.as_ref().and_then(|url| { - self.state.radio_stations.iter().position(|station| same_text(&station.url, url)) + self.state + .radio_stations + .iter() + .position(|station| same_text(&station.url, url)) }); self.radio_selection_was_user_set = self.state.selected_radio_index.is_some(); self.status_message = "Moved radio station.".to_owned(); @@ -451,6 +563,9 @@ fn valid_track_drop_target_for_playlist(playlist: &Playlist, from: usize, to: us if from >= playlist.tracks.len() || to > playlist.tracks.len() { return false; } + if playlist.kind == PlaylistKind::Temporary { + return false; + } if playlist.kind != PlaylistKind::Folder { return true; } diff --git a/src/app/playback.rs b/src/app/playback.rs index 880e026..46eb6a5 100644 --- a/src/app/playback.rs +++ b/src/app/playback.rs @@ -34,18 +34,17 @@ fn output_device_change_action( } fn output_device_change_message(output_name: &str) -> String { - format!( - "Output device changed to {output_name}. Refresh output to continue on the new device." - ) + format!("Output device changed to {output_name}. Refresh output to continue on the new device.") } impl AudioOrbitApp { pub(crate) fn add_profile(&mut self) { let settings = self.current_settings(); let number = self.state.profiles.len() + 1; - self.state - .profiles - .push(config::DspProfile::new(format!("Profile {number}"), settings)); + self.state.profiles.push(config::DspProfile::new( + format!("Profile {number}"), + settings, + )); self.state.selected_profile_index = self.state.profiles.len() - 1; self.status_message = "Created a new sound profile from the current settings.".to_owned(); self.save_state_silently(); @@ -56,7 +55,9 @@ impl AudioOrbitApp { return; } - self.state.profiles.remove(self.state.selected_profile_index); + self.state + .profiles + .remove(self.state.selected_profile_index); self.state.selected_profile_index = self.state.selected_profile_index.saturating_sub(1); self.status_message = "Removed sound profile.".to_owned(); self.save_state_silently(); @@ -72,7 +73,9 @@ impl AudioOrbitApp { return; }; - let start_seconds = self.saved_paused_resume_position_for_track(&path).unwrap_or(0.0); + let start_seconds = self + .saved_paused_resume_position_for_track(&path) + .unwrap_or(0.0); self.play_path(path, self.selected_track_index, start_seconds); } pub(crate) fn saved_paused_resume_position_for_track(&self, path: &Path) -> Option { @@ -121,14 +124,27 @@ impl AudioOrbitApp { } } - pub(crate) fn cached_track_for_path(&self, index: Option, path: &Path) -> Option<&Track> { + pub(crate) fn cached_track_for_path( + &self, + index: Option, + path: &Path, + ) -> Option<&Track> { let playlist = self.current_playlist()?; index .and_then(|index| playlist.tracks.get(index)) .filter(|track| same_path(&track.path, path)) - .or_else(|| playlist.tracks.iter().find(|track| same_path(&track.path, path))) + .or_else(|| { + playlist + .tracks + .iter() + .find(|track| same_path(&track.path, path)) + }) } - pub(crate) fn cached_waveform_for_track(&self, index: Option, path: &Path) -> Option<(Vec, Vec)> { + pub(crate) fn cached_waveform_for_track( + &self, + index: Option, + path: &Path, + ) -> Option<(Vec, Vec)> { let track = self.cached_track_for_path(index, path)?; if track.waveform.is_empty() || track.waveform_brightness.is_empty() { @@ -137,7 +153,11 @@ impl AudioOrbitApp { Some((track.waveform.clone(), track.waveform_brightness.clone())) } } - pub(crate) fn known_duration_for_track(&self, index: Option, path: &Path) -> Option { + pub(crate) fn known_duration_for_track( + &self, + index: Option, + path: &Path, + ) -> Option { self.cached_track_for_path(index, path) .and_then(|track| track.metadata.duration_seconds) .filter(|seconds| seconds.is_finite() && *seconds > 0.0) @@ -181,14 +201,21 @@ impl AudioOrbitApp { let entry = self.silence_analysis_cache.get(path)?; let (file_len, modified_nanos) = Self::audio_file_cache_identity(path); let settings = Self::silence_settings_fingerprint(settings); - if entry.file_len == file_len && entry.modified_nanos == modified_nanos && entry.settings == settings { + if entry.file_len == file_len + && entry.modified_nanos == modified_nanos + && entry.settings == settings + { Some(entry.ranges.clone()) } else { None } } fn silence_adjusted_seek_position(seconds: f32, silence_ranges: Option<&[(f32, f32)]>) -> f32 { - let mut position = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 }; + let mut position = if seconds.is_finite() { + seconds.max(0.0) + } else { + 0.0 + }; let Some(ranges) = silence_ranges else { return position; }; @@ -274,7 +301,8 @@ impl AudioOrbitApp { live_position_compensation: bool, ) { if self.player.is_none() { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = + Some("No audio output device is available. Try Refresh output device.".to_owned()); return; } if !self.ensure_track_available_for_playback(&path) { @@ -285,7 +313,8 @@ impl AudioOrbitApp { let playlist_index = self.state.selected_playlist_index; let cached_waveform = self.cached_waveform_for_track(index, &path); let cached_silence_ranges = self.cached_silence_ranges_for_track(&path, settings); - let start_seconds = Self::silence_adjusted_seek_position(start_seconds, cached_silence_ranges.as_deref()); + let start_seconds = + Self::silence_adjusted_seek_position(start_seconds, cached_silence_ranges.as_deref()); let known_duration_seconds = self.known_duration_for_track(index, &path); if !settings.skip_silence_enabled { @@ -327,14 +356,23 @@ impl AudioOrbitApp { self.selected_track_index = index; self.active_track_index = index; self.active_track_path = Some(info.path.clone()); - self.request_active_track_scroll_if_changed(previous_track_index, previous_track_path, index, &info.path); + self.request_active_track_scroll_if_changed( + previous_track_index, + previous_track_path, + index, + &info.path, + ); self.pending_track_switch = None; self.crossfade_started_for_path = None; self.store_playback_metadata(&info); self.remember_last_played_track(index, &info.path); self.last_playback = Some(info.clone()); self.status_message = if live_position_compensation { - format!("Applied sound profile and continued {} through {}.", display_file_name(&info.path), mode_label) + format!( + "Applied sound profile and continued {} through {}.", + display_file_name(&info.path), + mode_label + ) } else if crossfade_seconds > 0.05 { format!( "Crossfading to {} through {}; previous source is fading out.", @@ -342,7 +380,11 @@ impl AudioOrbitApp { mode_label ) } else { - format!("Playing {} through {}.", display_file_name(&info.path), mode_label) + format!( + "Playing {} through {}.", + display_file_name(&info.path), + mode_label + ) }; self.persist_playback_session(); self.save_state_silently(); @@ -397,7 +439,12 @@ impl AudioOrbitApp { self.selected_track_index = index; self.active_track_index = index; self.active_track_path = Some(info.path.clone()); - self.request_active_track_scroll_if_changed(previous_track_index, previous_track_path, index, &info.path); + self.request_active_track_scroll_if_changed( + previous_track_index, + previous_track_path, + index, + &info.path, + ); self.pending_track_switch = None; self.crossfade_started_for_path = None; self.store_playback_metadata(&info); @@ -429,20 +476,31 @@ impl AudioOrbitApp { crossfade_seconds ) } else { - format!("Fast playback failed; preparing {}...", display_file_name(&path)) + format!( + "Fast playback failed; preparing {}...", + display_file_name(&path) + ) }; self.error_message = Some(error.to_string()); } } - let background_crossfade_seconds = if quick_started { 0.0 } else { crossfade_seconds }; + let background_crossfade_seconds = if quick_started { + 0.0 + } else { + crossfade_seconds + }; let background_live_position_compensation = quick_started || live_position_compensation; let background_upgrade = quick_started; let (sender, receiver) = mpsc::channel(); let path_for_thread = path.clone(); self.pending_prepared_track_receiver = Some(receiver); - self.error_message = if quick_started { None } else { self.error_message.take() }; + self.error_message = if quick_started { + None + } else { + self.error_message.take() + }; thread::spawn(move || { let result = AudioPlayer::prepare_file_with_cached_analysis( @@ -452,16 +510,16 @@ impl AudioOrbitApp { cached_waveform, cached_silence_ranges, ) - .map(|prepared| PreparedTrackPlayback { - playlist_index, - index, - crossfade_seconds: background_crossfade_seconds, - live_position_compensation: background_live_position_compensation, - background_upgrade, - prepared, - requested_at, - }) - .map_err(|error| error.to_string()); + .map(|prepared| PreparedTrackPlayback { + playlist_index, + index, + crossfade_seconds: background_crossfade_seconds, + live_position_compensation: background_live_position_compensation, + background_upgrade, + prepared, + requested_at, + }) + .map_err(|error| error.to_string()); let _ = sender.send(result); }); } @@ -508,7 +566,8 @@ impl AudioOrbitApp { ); let Some(player) = &mut self.player else { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = + Some("No audio output device is available. Try Refresh output device.".to_owned()); return; }; @@ -544,16 +603,28 @@ impl AudioOrbitApp { self.active_track_index = index; self.active_track_path = Some(info.path.clone()); - self.request_active_track_scroll_if_changed(previous_track_index, previous_track_path, index, &info.path); + self.request_active_track_scroll_if_changed( + previous_track_index, + previous_track_path, + index, + &info.path, + ); self.pending_track_switch = None; self.crossfade_started_for_path = None; self.store_playback_metadata(&info); self.remember_last_played_track(index, &info.path); self.last_playback = Some(info.clone()); self.status_message = if background_upgrade { - format!("Silence-skip preparation finished for {}.", display_file_name(&info.path)) + format!( + "Silence-skip preparation finished for {}.", + display_file_name(&info.path) + ) } else if live_position_compensation { - format!("Applied sound profile and continued {} through {}.", display_file_name(&info.path), mode_label) + format!( + "Applied sound profile and continued {} through {}.", + display_file_name(&info.path), + mode_label + ) } else if crossfade_seconds > 0.05 { format!( "Crossfading to {} through {}; previous source is fading out.", @@ -561,7 +632,11 @@ impl AudioOrbitApp { mode_label ) } else { - format!("Playing {} through {}.", display_file_name(&info.path), mode_label) + format!( + "Playing {} through {}.", + display_file_name(&info.path), + mode_label + ) }; self.persist_playback_session(); self.save_state_silently(); @@ -599,8 +674,11 @@ impl AudioOrbitApp { } else if self.state.playback.shuffle_enabled { self.random_sequence_index(&indexes, current_index)? } else { - let current_position = current_index.and_then(|index| indexes.iter().position(|candidate| *candidate == index)); - let next_position = current_position.map(|position| (position + 1) % indexes.len()).unwrap_or(0); + let current_position = current_index + .and_then(|index| indexes.iter().position(|candidate| *candidate == index)); + let next_position = current_position + .map(|position| (position + 1) % indexes.len()) + .unwrap_or(0); indexes[next_position] }; @@ -638,9 +716,16 @@ impl AudioOrbitApp { .filter(|index| indexes.contains(index)) .unwrap_or(indexes[0]) } else { - let current_position = current_index.and_then(|index| indexes.iter().position(|candidate| *candidate == index)); + let current_position = current_index + .and_then(|index| indexes.iter().position(|candidate| *candidate == index)); let previous_position = current_position - .map(|position| if position == 0 { indexes.len() - 1 } else { position - 1 }) + .map(|position| { + if position == 0 { + indexes.len() - 1 + } else { + position - 1 + } + }) .unwrap_or(0); indexes[previous_position] }; @@ -660,7 +745,10 @@ impl AudioOrbitApp { pub(crate) fn current_waveform_for_seek(&self) -> Option<(Vec, Vec)> { if let Some(playback) = &self.last_playback { if !playback.waveform.is_empty() && !playback.waveform_brightness.is_empty() { - return Some((playback.waveform.clone(), playback.waveform_brightness.clone())); + return Some(( + playback.waveform.clone(), + playback.waveform_brightness.clone(), + )); } } @@ -709,7 +797,8 @@ impl AudioOrbitApp { return; } - let duration = known_duration_seconds.unwrap_or_else(|| self.displayed_playback_duration_seconds()); + let duration = + known_duration_seconds.unwrap_or_else(|| self.displayed_playback_duration_seconds()); let requested_position_seconds = if duration.is_finite() && duration > 0.0 { seconds.clamp(0.0, duration) } else { @@ -721,7 +810,9 @@ impl AudioOrbitApp { cached_silence_ranges.as_deref(), ); let now = Instant::now(); - let playlist_index = self.active_playlist_index.unwrap_or(self.state.selected_playlist_index); + let playlist_index = self + .active_playlist_index + .unwrap_or(self.state.selected_playlist_index); let index = self.active_track_index; // Any new seek makes older prepared results stale. The user-visible path is the @@ -733,7 +824,9 @@ impl AudioOrbitApp { let can_restart_now = self.pending_fast_seek.is_none() && self .last_fast_seek_started_at - .map(|started| now.saturating_duration_since(started) >= FAST_SEEK_COALESCE_INTERVAL) + .map(|started| { + now.saturating_duration_since(started) >= FAST_SEEK_COALESCE_INTERVAL + }) .unwrap_or(true); if can_restart_now { @@ -851,7 +944,10 @@ impl AudioOrbitApp { return; } - let pending = self.pending_fast_seek.take().expect("pending fast seek was checked above"); + let pending = self + .pending_fast_seek + .take() + .expect("pending fast seek was checked above"); if self .active_track_path .as_ref() @@ -880,7 +976,10 @@ impl AudioOrbitApp { return; } - let pending = self.pending_seek_prepare.take().expect("pending seek prepare was checked above"); + let pending = self + .pending_seek_prepare + .take() + .expect("pending seek prepare was checked above"); if self .active_track_path .as_ref() @@ -940,7 +1039,11 @@ impl AudioOrbitApp { pub(crate) fn profile_apply_status_text(&self) -> Option { let now = Instant::now(); if let Some(apply_at) = self.pending_profile_apply_at { - let seconds = apply_at.saturating_duration_since(now).as_secs_f32().ceil().max(1.0) as u64; + let seconds = apply_at + .saturating_duration_since(now) + .as_secs_f32() + .ceil() + .max(1.0) as u64; return Some(format!("Apply in {seconds}s...")); } if self @@ -999,14 +1102,23 @@ impl AudioOrbitApp { let Some(player) = &self.player else { return; }; - (player.is_playing() || player.is_paused(), player.is_playing()) + ( + player.is_playing() || player.is_paused(), + player.is_playing(), + ) }; if !is_active { return; } - self.prepare_track_playback(path, self.active_track_index, position, 0.0, live_position_compensation); + self.prepare_track_playback( + path, + self.active_track_index, + position, + 0.0, + live_position_compensation, + ); } pub(crate) fn process_pending_track_switch(&mut self) { let Some(pending) = self.pending_track_switch.clone() else { @@ -1024,7 +1136,12 @@ impl AudioOrbitApp { self.active_playlist_index = Some(pending.playlist_index); self.active_track_path = Some(pending.info.path.clone()); self.selected_track_index = pending.index; - self.request_active_track_scroll_if_changed(previous_track_index, previous_track_path, pending.index, &pending.info.path); + self.request_active_track_scroll_if_changed( + previous_track_index, + previous_track_path, + pending.index, + &pending.info.path, + ); self.crossfade_started_for_path = None; self.remember_last_played_track(pending.index, &pending.info.path); self.store_playback_metadata(&pending.info); @@ -1062,7 +1179,11 @@ impl AudioOrbitApp { self.last_playback .as_ref() .map(|playback| playback.original_duration_seconds) - .or_else(|| self.player.as_ref().and_then(AudioPlayer::playback_duration_seconds)) + .or_else(|| { + self.player + .as_ref() + .and_then(AudioPlayer::playback_duration_seconds) + }) .unwrap_or(0.0) } pub(crate) fn stop(&mut self) { @@ -1182,7 +1303,9 @@ impl AudioOrbitApp { pub(crate) fn set_volume_percent(&mut self, volume_percent: u8) { let next_volume = volume_percent.clamp(0, 100); let next_muted = next_volume == 0; - if self.state.playback.volume_percent == next_volume && self.state.playback.muted == next_muted { + if self.state.playback.volume_percent == next_volume + && self.state.playback.muted == next_muted + { return; } @@ -1219,18 +1342,27 @@ impl AudioOrbitApp { let next = (current + delta_percent).clamp(0, 100) as u8; self.set_volume_percent(next); } - pub(crate) fn handle_top_panel_volume_wheel(&mut self, response: &egui::Response, context: &egui::Context) { + pub(crate) fn handle_top_panel_volume_wheel( + &mut self, + response: &egui::Response, + context: &egui::Context, + ) { if !response.hovered() { return; } - let scroll_y = context.input(|input| input.raw_scroll_delta.y + input.smooth_scroll_delta.y); + let scroll_y = + context.input(|input| input.raw_scroll_delta.y + input.smooth_scroll_delta.y); if scroll_y.abs() < 0.5 { return; } let steps = (scroll_y / 80.0).round() as i16; - let steps = if steps == 0 { scroll_y.signum() as i16 } else { steps }; + let steps = if steps == 0 { + scroll_y.signum() as i16 + } else { + steps + }; self.adjust_volume(steps * 2); } pub(crate) fn seek_relative(&mut self, delta_seconds: f32) { @@ -1243,11 +1375,41 @@ impl AudioOrbitApp { } let current = self.displayed_playback_position_seconds(); - let duration = self.displayed_playback_duration_seconds().max(current.max(0.0)); + let duration = self + .displayed_playback_duration_seconds() + .max(current.max(0.0)); let next = (current + delta_seconds).clamp(0.0, duration.max(0.0)); self.seek_current(next); } pub(crate) fn update_playback_status(&mut self) { + let dj_preview = self.dj_mix_modal.as_ref().and_then(|modal| { + modal + .preview_track_path + .as_ref() + .map(|path| (path.clone(), modal.preview_stop_seconds)) + }); + if let Some((preview_path, stop_seconds)) = dj_preview { + let preview_is_active = self + .active_track_path + .as_ref() + .map(|active| same_path(active, &preview_path)) + .unwrap_or(false); + if preview_is_active { + let finished = self + .player + .as_ref() + .map(AudioPlayer::has_finished) + .unwrap_or(false); + let reached_range_end = stop_seconds + .map(|end| self.displayed_playback_position_seconds() >= end.max(0.0)) + .unwrap_or(false); + if finished || reached_range_end { + self.stop_dj_preview_playback(); + } + } + return; + } + if self.maybe_start_crossfade_to_next_track() { return; } @@ -1259,7 +1421,9 @@ impl AudioOrbitApp { .unwrap_or(false); if finished && self.active_track_index.is_some() { - if self.state.playback.auto_advance || self.state.playback.repeat_mode != RepeatMode::Off { + if self.state.playback.auto_advance + || self.state.playback.repeat_mode != RepeatMode::Off + { self.play_next_track_with_crossfade(0.0); } else { self.active_track_index = None; @@ -1365,12 +1529,7 @@ mod tests { #[test] fn clears_pending_change_when_output_returns_to_active_device() { assert_eq!( - output_device_change_action( - "Speakers", - Some("Headphones"), - "Speakers", - false, - ), + output_device_change_action("Speakers", Some("Headphones"), "Speakers", false,), OutputDeviceChangeAction::ClearPending ); } @@ -1378,12 +1537,7 @@ mod tests { #[test] fn does_not_repeat_action_for_same_detected_output() { assert_eq!( - output_device_change_action( - "Speakers", - Some("Headphones"), - "Headphones", - true, - ), + output_device_change_action("Speakers", Some("Headphones"), "Headphones", true,), OutputDeviceChangeAction::None ); } diff --git a/src/app/playlist_files.rs b/src/app/playlist_files.rs index ce0b4ea..ecaf313 100644 --- a/src/app/playlist_files.rs +++ b/src/app/playlist_files.rs @@ -12,6 +12,14 @@ impl AudioOrbitApp { } pub(crate) fn export_current_playlist_to_folder(&mut self) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } if !self.track_file_operations_idle() { self.error_message = Some("Wait for current library or track file operation to finish.".to_owned()); @@ -44,6 +52,14 @@ impl AudioOrbitApp { } pub(crate) fn copy_track_selection_to_folder(&mut self, index: usize) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } if !self.track_file_operations_idle() { self.error_message = Some("Wait for current library or track file operation to finish.".to_owned()); @@ -63,6 +79,14 @@ impl AudioOrbitApp { } pub(crate) fn request_delete_current_playlist_files(&mut self) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } if !self.track_file_operations_idle() { self.error_message = Some("Wait for current library or track file operation to finish.".to_owned()); @@ -100,6 +124,14 @@ impl AudioOrbitApp { } pub(crate) fn request_delete_track_selection(&mut self, index: usize) { + if self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false) + { + self.error_message = Some("Temporary playback is read-only.".to_owned()); + return; + } if !self.track_file_operations_idle() { self.error_message = Some("Wait for current library or track file operation to finish.".to_owned()); @@ -423,7 +455,23 @@ impl AudioOrbitApp { .iter() .filter(|path| playlist.add_track_path((*path).clone(), None, 0)) .count(); - self.state.playlists.push(playlist); + let insert_index = self + .state + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(self.state.playlists.len()); + self.state.playlists.insert(insert_index, playlist); + if self.state.selected_playlist_index >= insert_index { + self.state.selected_playlist_index += 1; + } + if self + .active_playlist_index + .map(|index| index >= insert_index) + .unwrap_or(false) + { + self.active_playlist_index = self.active_playlist_index.map(|index| index + 1); + } self.status_message = format!("Created {name} and added {added} of {requested} track(s)."); self.error_message = None; self.save_state_silently(); diff --git a/src/app/playlist_state.rs b/src/app/playlist_state.rs index 2a2f9c9..ad9188e 100644 --- a/src/app/playlist_state.rs +++ b/src/app/playlist_state.rs @@ -5,7 +5,9 @@ impl AudioOrbitApp { self.state.playlists.get(self.state.selected_playlist_index) } pub(crate) fn current_playlist_mut(&mut self) -> Option<&mut Playlist> { - self.state.playlists.get_mut(self.state.selected_playlist_index) + self.state + .playlists + .get_mut(self.state.selected_playlist_index) } pub(crate) fn select_playlist(&mut self, index: usize) { if index >= self.state.playlists.len() { @@ -28,18 +30,22 @@ impl AudioOrbitApp { self.pending_folder_watch_full_rescan = false; self.save_state_silently(); } - pub(crate) fn jump_to_track_in_playlist(&mut self, playlist_index: usize, expected_path: PathBuf) { - let Some((track_path, playlist_name)) = self - .state - .playlists - .get(playlist_index) - .and_then(|playlist| { - playlist - .tracks - .iter() - .find(|track| same_path(&track.path, &expected_path)) - .map(|track| (track.path.clone(), playlist.name.clone())) - }) + pub(crate) fn jump_to_track_in_playlist( + &mut self, + playlist_index: usize, + expected_path: PathBuf, + ) { + let Some((track_path, playlist_name)) = + self.state + .playlists + .get(playlist_index) + .and_then(|playlist| { + playlist + .tracks + .iter() + .find(|track| same_path(&track.path, &expected_path)) + .map(|track| (track.path.clone(), playlist.name.clone())) + }) else { return; }; @@ -83,7 +89,9 @@ impl AudioOrbitApp { playlist .repeat_selection .iter() - .any(|selected_path| same_path(selected_path.as_path(), track.path.as_path())) + .any(|selected_path| { + same_path(selected_path.as_path(), track.path.as_path()) + }) .then_some(index) }) .collect::>() @@ -187,7 +195,9 @@ impl AudioOrbitApp { }) .collect::>(); - if self.state.playback.repeat_mode == RepeatMode::Selection && !self.selected_track_indexes.is_empty() { + if self.state.playback.repeat_mode == RepeatMode::Selection + && !self.selected_track_indexes.is_empty() + { indexes .into_iter() .filter(|index| self.selected_track_indexes.contains(index)) @@ -232,10 +242,16 @@ impl AudioOrbitApp { if modifiers.shift { let visible_indexes = self.visible_track_indexes(); let anchor = self.track_selection_anchor_index.unwrap_or(index); - let anchor_position = visible_indexes.iter().position(|candidate| *candidate == anchor); - let target_position = visible_indexes.iter().position(|candidate| *candidate == index); + let anchor_position = visible_indexes + .iter() + .position(|candidate| *candidate == anchor); + let target_position = visible_indexes + .iter() + .position(|candidate| *candidate == index); - if let (Some(anchor_position), Some(target_position)) = (anchor_position, target_position) { + if let (Some(anchor_position), Some(target_position)) = + (anchor_position, target_position) + { if !additive { self.multi_selected_track_indexes.clear(); } @@ -298,7 +314,12 @@ impl AudioOrbitApp { self.action_track_indexes_for_context(index) .into_iter() - .filter_map(|track_index| playlist.tracks.get(track_index).map(|track| track.path.clone())) + .filter_map(|track_index| { + playlist + .tracks + .get(track_index) + .map(|track| track.path.clone()) + }) .collect() } pub(crate) fn remember_last_played_track(&mut self, index: Option, path: &Path) { @@ -306,7 +327,9 @@ impl AudioOrbitApp { return; }; self.state.last_played_track = Some(LastPlayedTrack { - playlist_index: self.active_playlist_index.unwrap_or(self.state.selected_playlist_index), + playlist_index: self + .active_playlist_index + .unwrap_or(self.state.selected_playlist_index), track_path: path.to_path_buf(), }); self.selected_track_index = Some(track_index); @@ -334,7 +357,8 @@ impl AudioOrbitApp { let session = self.state.playback_session.clone(); match session.source.as_str() { "radio" => { - let Some(radio_index) = session.radio_index.or(self.state.selected_radio_index) else { + let Some(radio_index) = session.radio_index.or(self.state.selected_radio_index) + else { return; }; if radio_index >= self.state.radio_stations.len() { @@ -349,7 +373,8 @@ impl AudioOrbitApp { } } "track" | "music" => { - let Some((playlist_index, track_index, path)) = self.find_session_track(&session) else { + let Some((playlist_index, track_index, path)) = self.find_session_track(&session) + else { return; }; self.active_tab = MainContentTab::Music; @@ -364,26 +389,50 @@ impl AudioOrbitApp { _ => {} } } - pub(crate) fn find_session_track(&self, session: &PlaybackSession) -> Option<(usize, usize, PathBuf)> { - let session_path = session - .track_path - .as_ref() - .or_else(|| self.state.last_played_track.as_ref().map(|track| &track.track_path))?; - let preferred_playlist = session - .playlist_index - .or_else(|| self.state.last_played_track.as_ref().map(|track| track.playlist_index)); + pub(crate) fn find_session_track( + &self, + session: &PlaybackSession, + ) -> Option<(usize, usize, PathBuf)> { + let session_path = session.track_path.as_ref().or_else(|| { + self.state + .last_played_track + .as_ref() + .map(|track| &track.track_path) + })?; + let preferred_playlist = session.playlist_index.or_else(|| { + self.state + .last_played_track + .as_ref() + .map(|track| track.playlist_index) + }); if let Some(playlist_index) = preferred_playlist { if let Some(playlist) = self.state.playlists.get(playlist_index) { - if let Some(track_index) = playlist.tracks.iter().position(|track| !track.missing && same_path(&track.path, session_path)) { - return Some((playlist_index, track_index, playlist.tracks[track_index].path.clone())); + if let Some(track_index) = playlist + .tracks + .iter() + .position(|track| !track.missing && same_path(&track.path, session_path)) + { + return Some(( + playlist_index, + track_index, + playlist.tracks[track_index].path.clone(), + )); } } } for (playlist_index, playlist) in self.state.playlists.iter().enumerate() { - if let Some(track_index) = playlist.tracks.iter().position(|track| !track.missing && same_path(&track.path, session_path)) { - return Some((playlist_index, track_index, playlist.tracks[track_index].path.clone())); + if let Some(track_index) = playlist + .tracks + .iter() + .position(|track| !track.missing && same_path(&track.path, session_path)) + { + return Some(( + playlist_index, + track_index, + playlist.tracks[track_index].path.clone(), + )); } } @@ -413,7 +462,11 @@ impl AudioOrbitApp { && self.active_radio_index.is_some(); session.source = "radio".to_owned(); session.was_active = is_active; - session.was_paused = self.player.as_ref().map(|player| player.is_paused()).unwrap_or(false); + session.was_paused = self + .player + .as_ref() + .map(|player| player.is_paused()) + .unwrap_or(false); session.radio_index = Some(radio_index); self.state.selected_radio_index = Some(radio_index); self.state.playback_session = session; @@ -424,11 +477,21 @@ impl AudioOrbitApp { .active_track_path .clone() .or_else(|| self.selected_track_path()) - .or_else(|| self.state.last_played_track.as_ref().map(|track| track.track_path.clone())); + .or_else(|| { + self.state + .last_played_track + .as_ref() + .map(|track| track.track_path.clone()) + }); if let Some(path) = track_path { let playlist_index = self .active_playlist_index - .or_else(|| self.state.last_played_track.as_ref().map(|track| track.playlist_index)) + .or_else(|| { + self.state + .last_played_track + .as_ref() + .map(|track| track.playlist_index) + }) .unwrap_or(self.state.selected_playlist_index); let is_active = self .player @@ -436,7 +499,11 @@ impl AudioOrbitApp { .map(|player| player.is_playing() || player.is_paused()) .unwrap_or(false) && self.active_track_path.is_some(); - let player_is_paused = self.player.as_ref().map(|player| player.is_paused()).unwrap_or(false); + let player_is_paused = self + .player + .as_ref() + .map(|player| player.is_paused()) + .unwrap_or(false); let preserved_paused_session_position = (!is_active && self.state.playback_session.was_paused && self @@ -472,7 +539,9 @@ impl AudioOrbitApp { .active_radio_title .clone() .or_else(|| station.last_stream_title.clone()) - .filter(|title| !title.trim().is_empty() && !title.eq_ignore_ascii_case(&station.name)); + .filter(|title| { + !title.trim().is_empty() && !title.eq_ignore_ascii_case(&station.name) + }); let station_name = self .active_radio_station_name .clone() @@ -498,7 +567,10 @@ impl AudioOrbitApp { .or_else(|| station.last_station_name.clone()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| station.name.clone()); - let elapsed = self.radio_elapsed_seconds().map(format_duration).unwrap_or_else(|| "0:00".to_owned()); + let elapsed = self + .radio_elapsed_seconds() + .map(format_duration) + .unwrap_or_else(|| "0:00".to_owned()); format!("{station_name} · live for {elapsed} · {}", station.url) }); } @@ -509,7 +581,10 @@ impl AudioOrbitApp { "{}k · {} ch · {}", playback.sample_rate / 1000, playback.input_channels, - playback.size_bytes.map(format_file_size).unwrap_or_else(|| "unknown size".to_owned()) + playback + .size_bytes + .map(format_file_size) + .unwrap_or_else(|| "unknown size".to_owned()) ) } else { format!( @@ -517,7 +592,10 @@ impl AudioOrbitApp { display_parent(&playback.path), playback.sample_rate, playback.input_channels, - playback.size_bytes.map(format_file_size).unwrap_or_else(|| "unknown size".to_owned()), + playback + .size_bytes + .map(format_file_size) + .unwrap_or_else(|| "unknown size".to_owned()), format_duration(playback.original_duration_seconds) ) } @@ -536,15 +614,24 @@ impl AudioOrbitApp { .waveform_drag_position_seconds .unwrap_or_else(|| self.displayed_playback_position_seconds()); let duration = self.displayed_playback_duration_seconds(); - return format!("{} / {}", format_duration(position), format_duration(duration)); + return format!( + "{} / {}", + format_duration(position), + format_duration(duration) + ); } String::new() } pub(crate) fn radio_elapsed_seconds(&self) -> Option { - self.radio_started_at.map(|started_at| started_at.elapsed().as_secs_f32()) - } - pub(crate) fn random_sequence_index(&self, indexes: &[usize], current_index: Option) -> Option { + self.radio_started_at + .map(|started_at| started_at.elapsed().as_secs_f32()) + } + pub(crate) fn random_sequence_index( + &self, + indexes: &[usize], + current_index: Option, + ) -> Option { if indexes.is_empty() { return None; } @@ -558,7 +645,11 @@ impl AudioOrbitApp { .copied() .filter(|index| Some(*index) != current_index) .collect::>(); - let candidates = if candidates.is_empty() { indexes.to_vec() } else { candidates }; + let candidates = if candidates.is_empty() { + indexes.to_vec() + } else { + candidates + }; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos() as usize) @@ -572,7 +663,12 @@ impl AudioOrbitApp { }; self.current_playlist() - .and_then(|playlist| playlist.tracks.get(index).map(|track| playlist.track_matches_selected_group(track))) + .and_then(|playlist| { + playlist + .tracks + .get(index) + .map(|track| playlist.track_matches_selected_group(track)) + }) .unwrap_or(false) } pub(crate) fn ensure_selected_track_visible(&mut self) { @@ -589,7 +685,12 @@ impl AudioOrbitApp { pub(crate) fn is_favorite(&self, path: &Path) -> bool { self.favorites_index() .and_then(|index| self.state.playlists.get(index)) - .map(|playlist| playlist.tracks.iter().any(|track| same_path(&track.path, path))) + .map(|playlist| { + playlist + .tracks + .iter() + .any(|track| same_path(&track.path, path)) + }) .unwrap_or(false) } pub(crate) fn toggle_favorite(&mut self, path: PathBuf) { @@ -598,12 +699,16 @@ impl AudioOrbitApp { }; let favorites_is_selected = self.state.selected_playlist_index == favorites_index; - let selected_path = favorites_is_selected.then(|| self.selected_track_path()).flatten(); + let selected_path = favorites_is_selected + .then(|| self.selected_track_path()) + .flatten(); let selected_index = self.selected_track_index.unwrap_or(0); let is_favorite = self.is_favorite(&path); if let Some(favorites) = self.state.playlists.get_mut(favorites_index) { if is_favorite { - favorites.tracks.retain(|track| !same_path(&track.path, &path)); + favorites + .tracks + .retain(|track| !same_path(&track.path, &path)); self.status_message = "Removed from Favorites.".to_owned(); } else { favorites.add_track_path(path.clone(), None, 0); @@ -642,7 +747,8 @@ impl AudioOrbitApp { }; if !playlist.accepts_manual_tracks() { - self.error_message = Some("Folder playlists are scanner-owned and cannot receive manual tracks.".to_owned()); + self.error_message = + Some("This playlist is read-only and cannot receive manual tracks.".to_owned()); return; } @@ -672,7 +778,10 @@ impl AudioOrbitApp { } pub(crate) fn remove_track_from_current_playlist(&mut self, track_index: usize) { let Some(remaining_len) = self.current_playlist_mut().and_then(|playlist| { - if playlist.kind == PlaylistKind::Folder { + if matches!( + playlist.kind, + PlaylistKind::Folder | PlaylistKind::Temporary + ) { None } else if track_index < playlist.tracks.len() { playlist.tracks.remove(track_index); @@ -681,7 +790,7 @@ impl AudioOrbitApp { None } }) else { - self.error_message = Some("Folder playlist entries are managed by folder rescan.".to_owned()); + self.error_message = Some("This playlist is read-only.".to_owned()); return; }; @@ -726,12 +835,15 @@ impl AudioOrbitApp { .active_playlist_index .zip(self.active_track_path.as_ref()) .and_then(|(playlist_index, active_path)| { - self.state.playlists.get(playlist_index).and_then(|playlist| { - playlist - .tracks - .iter() - .position(|track| same_path(&track.path, active_path)) - }) + self.state + .playlists + .get(playlist_index) + .and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, active_path)) + }) }); self.restore_repeat_selection_for_current_playlist(); self.status_message = format!("Removed missing entry {}.", display_file_name(&path)); @@ -739,14 +851,21 @@ impl AudioOrbitApp { self.save_state_silently(); } pub(crate) fn delete_track_from_disk(&mut self, path: PathBuf) { - if self.active_track_path.as_ref().map(|active| same_path(active, &path)).unwrap_or(false) { + if self + .active_track_path + .as_ref() + .map(|active| same_path(active, &path)) + .unwrap_or(false) + { self.stop(); } match fs::remove_file(&path) { Ok(()) => { for playlist in &mut self.state.playlists { - playlist.tracks.retain(|track| !same_path(&track.path, &path)); + playlist + .tracks + .retain(|track| !same_path(&track.path, &path)); } self.selected_track_index = self.eligible_track_indexes().first().copied(); self.clear_multi_track_selection(); diff --git a/src/app/radio.rs b/src/app/radio.rs index c1e0f84..b62434c 100644 --- a/src/app/radio.rs +++ b/src/app/radio.rs @@ -9,7 +9,8 @@ impl AudioOrbitApp { return false; } if !(url.starts_with("http://") || url.starts_with("https://")) { - self.error_message = Some("Internet radio stream URL must start with http:// or https://.".to_owned()); + self.error_message = + Some("Internet radio stream URL must start with http:// or https://.".to_owned()); return false; } @@ -68,7 +69,8 @@ impl AudioOrbitApp { let settings = self.current_settings(); let crossfade_seconds = self.configured_manual_crossfade_seconds(); let Some(player) = &mut self.player else { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = + Some("No audio output device is available. Try Refresh output device.".to_owned()); return; }; self.status_message = if crossfade_seconds > 0.05 { @@ -125,8 +127,14 @@ impl AudioOrbitApp { completed = true; if let Some(metadata) = metadata { if self.active_radio_index == Some(index) { - self.active_radio_station_name = metadata.station_name.clone().or_else(|| self.active_radio_station_name.clone()); - self.active_radio_title = metadata.stream_title.clone().or_else(|| self.active_radio_title.clone()); + self.active_radio_station_name = metadata + .station_name + .clone() + .or_else(|| self.active_radio_station_name.clone()); + self.active_radio_title = metadata + .stream_title + .clone() + .or_else(|| self.active_radio_title.clone()); } if let Some(station) = self.state.radio_stations.get_mut(index) { if metadata.station_name.is_some() { @@ -179,7 +187,9 @@ impl AudioOrbitApp { let is_recording = match self.player.as_ref() { Some(player) => player.is_radio_recording(), None => { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = Some( + "No audio output device is available. Try Refresh output device.".to_owned(), + ); return; } }; @@ -205,24 +215,27 @@ impl AudioOrbitApp { self.error_message = Some(error.to_string()); } None => { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = Some( + "No audio output device is available. Try Refresh output device." + .to_owned(), + ); } } return; } if self.active_radio_index.is_none() { - self.error_message = Some("Start an internet radio station before recording.".to_owned()); + self.error_message = + Some("Start an internet radio station before recording.".to_owned()); return; } let folder = self.state.recording.resolved_output_folder(); let station_name = self.current_radio_recording_name(); let stream_title = self.active_radio_title.clone(); - let result = self - .player - .as_mut() - .map(|player| player.start_radio_recording(&folder, &station_name, stream_title.as_deref())); + let result = self.player.as_mut().map(|player| { + player.start_radio_recording(&folder, &station_name, stream_title.as_deref()) + }); match result { Some(Ok(path)) => { self.status_message = format!("Recording internet radio to {}.", path.display()); @@ -232,7 +245,9 @@ impl AudioOrbitApp { self.error_message = Some(error.to_string()); } None => { - self.error_message = Some("No audio output device is available. Try Refresh output device.".to_owned()); + self.error_message = Some( + "No audio output device is available. Try Refresh output device.".to_owned(), + ); } } } @@ -251,7 +266,10 @@ impl AudioOrbitApp { } pub(crate) fn open_recording_folder(&mut self) { let folder = self.state.recording.resolved_output_folder(); - if let Err(error) = fs::create_dir_all(&folder).and_then(|_| reveal_in_file_manager(&folder).map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error.to_string()))) { + if let Err(error) = fs::create_dir_all(&folder).and_then(|_| { + reveal_in_file_manager(&folder) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error.to_string())) + }) { self.error_message = Some(format!("Failed to open recording folder: {error}")); } else { self.status_message = format!("Opened radio recordings folder: {}.", folder.display()); diff --git a/src/app/ui_folder_import.rs b/src/app/ui_folder_import.rs index 004b65f..b4d68c8 100644 --- a/src/app/ui_folder_import.rs +++ b/src/app/ui_folder_import.rs @@ -72,7 +72,11 @@ impl AudioOrbitApp { }); }); }); - self.render_modal_info_footer_fixed(context, "folder_import_modal_info_footer", screen_rect); + self.render_modal_info_footer_fixed( + context, + "folder_import_modal_info_footer", + screen_rect, + ); if context.input(|input| input.key_pressed(egui::Key::Escape)) { close_after_import = true; diff --git a/src/app/ui_library.rs b/src/app/ui_library.rs index c97ada2..93c6c59 100644 --- a/src/app/ui_library.rs +++ b/src/app/ui_library.rs @@ -4,24 +4,45 @@ impl AudioOrbitApp { pub(crate) fn render_library_panel(&mut self, ui: &mut egui::Ui) { if self.active_tab == MainContentTab::Radio { ui.heading("Internet radio"); - ui.add(egui::Label::new("Local music library is not available while browsing internet radio.").wrap()); - ui.small("Use this side panel to switch between all radio stations and favorite stations."); + ui.add( + egui::Label::new( + "Local music library is not available while browsing internet radio.", + ) + .wrap(), + ); + ui.small( + "Use this side panel to switch between all radio stations and favorite stations.", + ); ui.separator(); - let favorite_count = self.state.radio_stations.iter().filter(|station| station.favorite).count(); + let favorite_count = self + .state + .radio_stations + .iter() + .filter(|station| station.favorite) + .count(); if ui - .selectable_label(!self.radio_show_favorites_only, format!("All stations ({})", self.state.radio_stations.len())) + .selectable_label( + !self.radio_show_favorites_only, + format!("All stations ({})", self.state.radio_stations.len()), + ) .clicked() { self.radio_show_favorites_only = false; } if ui - .selectable_label(self.radio_show_favorites_only, format!("Favorite stations ({favorite_count})")) + .selectable_label( + self.radio_show_favorites_only, + format!("Favorite stations ({favorite_count})"), + ) .clicked() { self.radio_show_favorites_only = true; } ui.separator(); - if ui.button(ui_icons::label(Icon::Settings2, "Settings...")).clicked() { + if ui + .button(ui_icons::label(Icon::Settings2, "Settings...")) + .clicked() + { self.open_panel_modal(AppPanelModal::Settings); } ui.add_space(16.0); @@ -45,7 +66,11 @@ impl AudioOrbitApp { let row = ui.horizontal(|ui| { let mut clicked_row = false; - let action_width = if show_actions && playlist.kind != PlaylistKind::Favorites { 88.0 } else { 0.0 }; + let action_width = if show_actions && playlist.kind.can_delete() { + 88.0 + } else { + 0.0 + }; let name_width = (ui.available_width() - action_width).max(120.0); if self.editing_playlist_index == Some(index) { @@ -56,13 +81,15 @@ impl AudioOrbitApp { ); if response.changed() { if let Some(target) = self.state.playlists.get_mut(index) { - if target.kind != PlaylistKind::Favorites { + if target.kind.can_delete() { target.name = next_name; } } self.save_state_silently(); } - if response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + if response.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter)) + { self.editing_playlist_index = None; } } else { @@ -70,7 +97,8 @@ impl AudioOrbitApp { egui::vec2(name_width, 42.0), egui::Layout::top_down(egui::Align::Min), |ui| { - let label = format!("{} {}", playlist.kind.icon(), playlist.name); + let label = + format!("{} {}", playlist.kind.icon(), playlist.name); if ui.selectable_label(selected, label).clicked() { clicked_row = true; } @@ -79,14 +107,26 @@ impl AudioOrbitApp { ); } - if show_actions && playlist.kind != PlaylistKind::Favorites { - if ui.small_button(ui_icons::icon(Icon::Pencil)).on_hover_text("Rename").clicked() { + if show_actions && playlist.kind.can_delete() { + if ui + .small_button(ui_icons::icon(Icon::Pencil)) + .on_hover_text("Rename") + .clicked() + { self.editing_playlist_index = Some(index); } - if ui.small_button(ui_icons::icon(Icon::ArrowUp)).on_hover_text("Move up").clicked() { + if ui + .small_button(ui_icons::icon(Icon::ArrowUp)) + .on_hover_text("Move up") + .clicked() + { self.move_playlist(index, -1); } - if ui.small_button(ui_icons::icon(Icon::ArrowDown)).on_hover_text("Move down").clicked() { + if ui + .small_button(ui_icons::icon(Icon::ArrowDown)) + .on_hover_text("Move down") + .clicked() + { self.move_playlist(index, 1); } } @@ -101,11 +141,23 @@ impl AudioOrbitApp { }); ui.horizontal(|ui| { - if ui.button(ui_icons::label(Icon::ListPlus, "New playlist")).clicked() { + if ui + .button(ui_icons::label(Icon::ListPlus, "New playlist")) + .clicked() + { self.add_playlist(); } - let can_remove = self.current_playlist().map(|playlist| playlist.kind.can_delete()).unwrap_or(false); - if ui.add_enabled(can_remove, egui::Button::new(ui_icons::label(Icon::Trash2, "Remove"))).clicked() { + let can_remove = self + .current_playlist() + .map(|playlist| playlist.kind.can_delete()) + .unwrap_or(false); + if ui + .add_enabled( + can_remove, + egui::Button::new(ui_icons::label(Icon::Trash2, "Remove")), + ) + .clicked() + { self.remove_current_playlist(); } }); @@ -117,11 +169,28 @@ impl AudioOrbitApp { .current_playlist() .map(|playlist| playlist.tracks.len()) .unwrap_or(0); + let current_available_track_count = self + .current_playlist() + .map(|playlist| { + playlist + .tracks + .iter() + .filter(|track| !track.missing && track.path.is_file()) + .count() + }) + .unwrap_or(0); let file_operation_idle = self.track_file_operations_idle(); + let current_is_temporary = self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false); + if current_is_temporary { + ui.small("Temporary playback is read-only. Files opened from Windows or completed DJ mixes stay here until app exit."); + } ui.horizontal_wrapped(|ui| { if ui .add_enabled( - current_track_count > 0 && file_operation_idle, + !current_is_temporary && current_track_count > 0 && file_operation_idle, egui::Button::new(ui_icons::label(Icon::Archive, "Export playlist...")), ) .on_hover_text("Copy every playlist file into a chosen folder. Original files stay unchanged; duplicate names receive a numeric suffix.") @@ -131,7 +200,7 @@ impl AudioOrbitApp { } if ui .add_enabled( - current_track_count > 0 && file_operation_idle, + !current_is_temporary && current_track_count > 0 && file_operation_idle, egui::Button::new(ui_icons::label(Icon::Trash2, "Delete all files...")), ) .on_hover_text("Permanently delete every file referenced by current playlist after typed confirmation.") @@ -139,15 +208,39 @@ impl AudioOrbitApp { { self.request_delete_current_playlist_files(); } + if ui + .add_enabled( + !current_is_temporary + && current_available_track_count >= 2 + && !self.dj_mix_is_running(), + egui::Button::new(ui_icons::label(Icon::Music, "DJ mix...")), + ) + .on_hover_text("Build one beat-aligned MP3 mix from current playlist without loading full tracks into memory.") + .clicked() + { + self.open_dj_mix_builder_for_current_playlist(); + } }); ui.separator(); ui.horizontal(|ui| { - let can_add_files = self.current_playlist().map(|playlist| playlist.accepts_manual_tracks()).unwrap_or(false); - if ui.add_enabled(can_add_files, egui::Button::new(ui_icons::label(Icon::FilePlus2, "Add files..."))).clicked() { + let can_add_files = self + .current_playlist() + .map(|playlist| playlist.accepts_manual_tracks()) + .unwrap_or(false); + if ui + .add_enabled( + can_add_files, + egui::Button::new(ui_icons::label(Icon::FilePlus2, "Add files...")), + ) + .clicked() + { self.add_audio_files(); } - if ui.button(ui_icons::label(Icon::FolderPlus, "Add folder...")).clicked() { + if ui + .button(ui_icons::label(Icon::FolderPlus, "Add folder...")) + .clicked() + { self.open_folder_import_modal(); } }); @@ -158,7 +251,7 @@ impl AudioOrbitApp { && self.pending_track_file_operation_receiver.is_none(); if ui .add_enabled( - scan_idle, + scan_idle && !current_is_temporary, egui::Button::new(ui_icons::label(Icon::RefreshCw, "Sync playlist")), ) .on_hover_text("Check only selected playlist. Folder playlists scan their source folder; manual playlists check saved files only.") @@ -169,7 +262,10 @@ impl AudioOrbitApp { }); ui.separator(); - if ui.button(ui_icons::label(Icon::Settings2, "Settings...")).clicked() { + if ui + .button(ui_icons::label(Icon::Settings2, "Settings...")) + .clicked() + { self.open_panel_modal(AppPanelModal::Settings); } ui.add_space(16.0); @@ -179,11 +275,18 @@ impl AudioOrbitApp { return; }; - ui.small(format!("Type: {} {}", playlist.kind.icon(), playlist.kind.label())); + ui.small(format!( + "Type: {} {}", + playlist.kind.icon(), + playlist.kind.label() + )); let groups = playlist.folder_groups(); let selected_group = playlist.selected_group.clone(); - let selected_label = playlist.selected_group.clone().unwrap_or_else(|| "Folder filter".to_owned()); + let selected_label = playlist + .selected_group + .clone() + .unwrap_or_else(|| "Folder filter".to_owned()); let source_folder = playlist.source_folder.clone(); let folder_depth = playlist.folder_depth; @@ -194,7 +297,8 @@ impl AudioOrbitApp { if groups.len() > 1 { let mut next_group = selected_group.clone(); - let group_dropdown_height = ((groups.len() + 1) as f32 * 24.0 + 36.0).clamp(180.0, 640.0); + let group_dropdown_height = + ((groups.len() + 1) as f32 * 24.0 + 36.0).clamp(180.0, 640.0); egui::ComboBox::from_id_salt("folder_group_selector") .selected_text(ellipsize_chars(&selected_label, 22)) .width(120.0) @@ -216,7 +320,9 @@ impl AudioOrbitApp { }); if next_group != selected_group { - self.remember_current_playlist_scroll_offset(self.state.ui.playlist_scroll_offset_y); + self.remember_current_playlist_scroll_offset( + self.state.ui.playlist_scroll_offset_y, + ); if let Some(playlist) = self.current_playlist_mut() { playlist.set_selected_group(next_group); } @@ -232,6 +338,24 @@ impl AudioOrbitApp { .current_playlist() .map(|playlist| playlist.tracks.len()) .unwrap_or(0); + let current_available_track_count = self + .current_playlist() + .map(|playlist| { + playlist + .tracks + .iter() + .filter(|track| !track.missing && track.path.is_file()) + .count() + }) + .unwrap_or(0); + let current_is_temporary = self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false); + if current_is_temporary { + ui.small("Temporary playback is read-only."); + return; + } let can_modify_files = current_track_count > 0 && self.track_file_operations_idle(); if ui @@ -258,6 +382,17 @@ impl AudioOrbitApp { ui.close_menu(); self.request_delete_current_playlist_files(); } + + if ui + .add_enabled( + current_available_track_count >= 2 && !self.dj_mix_is_running(), + egui::Button::new(ui_icons::label(Icon::Music, "Create DJ mix...")), + ) + .clicked() + { + ui.close_menu(); + self.open_dj_mix_builder_for_current_playlist(); + } } pub(crate) fn render_main_content_panel(&mut self, ui: &mut egui::Ui) { @@ -271,11 +406,23 @@ impl AudioOrbitApp { }); ui.horizontal(|ui| { - if ui.selectable_label(self.active_tab == MainContentTab::Music, ui_icons::label(Icon::Music, "Music")).clicked() { + if ui + .selectable_label( + self.active_tab == MainContentTab::Music, + ui_icons::label(Icon::Music, "Music"), + ) + .clicked() + { self.active_tab = MainContentTab::Music; self.save_state_silently(); } - if ui.selectable_label(self.active_tab == MainContentTab::Radio, ui_icons::label(Icon::Radio, "Internet radio")).clicked() { + if ui + .selectable_label( + self.active_tab == MainContentTab::Radio, + ui_icons::label(Icon::Radio, "Internet radio"), + ) + .clicked() + { self.active_tab = MainContentTab::Radio; self.save_state_silently(); } diff --git a/src/app/ui_modals.rs b/src/app/ui_modals.rs index c998897..ca529cd 100644 --- a/src/app/ui_modals.rs +++ b/src/app/ui_modals.rs @@ -17,7 +17,13 @@ impl AudioOrbitApp { ui.set_min_size(content_size); ui.set_max_width(content_size.x); - if Self::render_modal_header(ui, outer_padding.x, panel.icon(), panel.title(), panel.description()) { + if Self::render_modal_header( + ui, + outer_padding.x, + panel.icon(), + panel.title(), + panel.description(), + ) { self.close_panel_modal(); } @@ -28,9 +34,15 @@ impl AudioOrbitApp { ui.set_width(ui.available_width()); match panel { AppPanelModal::Settings => self.render_settings_panel_content(ui), - AppPanelModal::Updates => Self::render_modal_section(ui, |ui| self.render_updates_section_inner(ui, false)), - AppPanelModal::Backup => Self::render_modal_section(ui, |ui| self.render_backup_settings_section_inner(ui, false)), - AppPanelModal::About => Self::render_modal_section(ui, |ui| self.render_about_section_inner(ui, false)), + AppPanelModal::Updates => Self::render_modal_section(ui, |ui| { + self.render_updates_section_inner(ui, false) + }), + AppPanelModal::Backup => Self::render_modal_section(ui, |ui| { + self.render_backup_settings_section_inner(ui, false) + }), + AppPanelModal::About => Self::render_modal_section(ui, |ui| { + self.render_about_section_inner(ui, false) + }), } }); }); @@ -40,25 +52,97 @@ impl AudioOrbitApp { pub(crate) fn render_settings_panel_content(&mut self, ui: &mut egui::Ui) { Self::render_modal_section(ui, |ui| { ui.heading("Panels"); - ui.small("Open a separate panel. Esc or the top-right X returns to the previous panel."); + ui.small( + "Open a separate panel. Esc or the top-right X returns to the previous panel.", + ); ui.horizontal_wrapped(|ui| { - if ui.button(ui_icons::label(Icon::RefreshCw, "Updates")).clicked() { + if ui + .button(ui_icons::label(Icon::RefreshCw, "Updates")) + .clicked() + { self.open_panel_modal(AppPanelModal::Updates); } - if ui.button(ui_icons::label(Icon::Archive, "Backup")).clicked() { + if ui + .button(ui_icons::label(Icon::Archive, "Backup")) + .clicked() + { self.open_panel_modal(AppPanelModal::Backup); } if ui.button(ui_icons::label(Icon::Info, "About")).clicked() { self.open_panel_modal(AppPanelModal::About); } #[cfg(debug_assertions)] - if ui.button(ui_icons::label(Icon::Info, "Dev metrics")).clicked() { + if ui + .button(ui_icons::label(Icon::Info, "Dev metrics")) + .clicked() + { self.show_dev_metrics_window = true; } }); }); ui.add_space(8.0); + #[cfg(windows)] + { + Self::render_modal_section(ui, |ui| { + ui.heading("File associations"); + ui.small("Register Audio Orbit for supported audio formats, then choose it in Windows Default Apps. Opened files play through temporary playback and are cleared on next app start."); + ui.small(format!( + "Supported: {}", + file_associations::SUPPORTED_AUDIO_EXTENSIONS.join(", ") + )); + ui.horizontal_wrapped(|ui| { + if ui + .button(ui_icons::label(Icon::Music, "Associate audio files...")) + .clicked() + { + match std::env::current_exe() + .map_err(|error| error.to_string()) + .and_then(|path| file_associations::register(&path)) + { + Ok(()) => { + self.file_associations_registered = true; + self.status_message = + "Audio Orbit registered for supported audio files.".to_owned(); + if let Err(error) = file_associations::open_default_apps_settings() + { + self.error_message = Some(error); + } + } + Err(error) => self.error_message = Some(error), + } + } + if ui.button("Open Windows Default Apps").clicked() { + if let Err(error) = file_associations::open_default_apps_settings() { + self.error_message = Some(error); + } + } + if ui + .add_enabled( + self.file_associations_registered, + egui::Button::new("Remove registration"), + ) + .clicked() + { + match file_associations::unregister() { + Ok(()) => { + self.file_associations_registered = false; + self.status_message = + "Audio Orbit file-association registration removed.".to_owned(); + } + Err(error) => self.error_message = Some(error), + } + } + }); + ui.small(if self.file_associations_registered { + "Registration: installed. Windows still controls default-app selection per file type." + } else { + "Registration: not installed." + }); + }); + ui.add_space(8.0); + } + Self::render_modal_section(ui, |ui| { self.render_library_settings_section(ui); }); @@ -80,11 +164,23 @@ impl AudioOrbitApp { ui.add_space(2.0); } pub(crate) fn modal_outer_padding(screen_rect: egui::Rect) -> egui::Vec2 { - let horizontal = if screen_rect.width() < 560.0 { 16.0 } else { 24.0 }; - let vertical = if screen_rect.height() < 520.0 { 14.0 } else { 18.0 }; + let horizontal = if screen_rect.width() < 560.0 { + 16.0 + } else { + 24.0 + }; + let vertical = if screen_rect.height() < 520.0 { + 14.0 + } else { + 18.0 + }; egui::vec2(horizontal, vertical) } - pub(crate) fn modal_content_size(screen_rect: egui::Rect, _outer_padding: egui::Vec2, footer_height: f32) -> egui::Vec2 { + pub(crate) fn modal_content_size( + screen_rect: egui::Rect, + _outer_padding: egui::Vec2, + footer_height: f32, + ) -> egui::Vec2 { egui::vec2( screen_rect.width().max(280.0), (screen_rect.height() - footer_height).max(200.0), @@ -96,7 +192,13 @@ impl AudioOrbitApp { .corner_radius(egui::CornerRadius::same(0)) .inner_margin(egui::Margin::same(0)) } - pub(crate) fn render_modal_header(ui: &mut egui::Ui, horizontal_padding: f32, icon: Icon, title: &str, description: &str) -> bool { + pub(crate) fn render_modal_header( + ui: &mut egui::Ui, + horizontal_padding: f32, + icon: Icon, + title: &str, + description: &str, + ) -> bool { let mut close_clicked = false; let row_height = 34.0; @@ -113,7 +215,9 @@ impl AudioOrbitApp { close_clicked = ui .add_sized( egui::vec2(40.0, 30.0), - egui::Button::new(egui::RichText::new(ui_icons::icon(Icon::X)).size(17.0)), + egui::Button::new( + egui::RichText::new(ui_icons::icon(Icon::X)).size(17.0), + ), ) .on_hover_text("Close") .clicked(); @@ -135,10 +239,16 @@ impl AudioOrbitApp { let stroke = ui.visuals().widgets.noninteractive.bg_stroke; let y = ui.cursor().top().round(); let rect = ui.max_rect(); - ui.painter().line_segment([egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], stroke); + ui.painter().line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + stroke, + ); ui.add_space(1.0); } - pub(crate) fn render_modal_section(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { + pub(crate) fn render_modal_section( + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui), + ) { egui::Frame::new() .fill(egui::Color32::from_black_alpha(34)) .stroke(egui::Stroke::new(1.0, egui::Color32::from_black_alpha(58))) @@ -151,10 +261,8 @@ impl AudioOrbitApp { } pub(crate) fn render_modal_backdrop(&self, context: &egui::Context, id: &'static str) { let screen_rect = context.screen_rect(); - let painter = context.layer_painter(egui::LayerId::new( - egui::Order::Middle, - egui::Id::new(id), - )); + let painter = + context.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new(id))); painter.rect_filled(screen_rect, 0.0, egui::Color32::from_black_alpha(156)); } pub(crate) fn render_details_modal(&mut self, context: &egui::Context) { @@ -273,13 +381,16 @@ impl AudioOrbitApp { let active_location = self .active_playlist_index .and_then(|playlist_index| { - self.state.playlists.get(playlist_index).and_then(|playlist| { - playlist - .tracks - .iter() - .position(|track| same_path(&track.path, &active_path)) - .map(|track_index| (playlist_index, track_index)) - }) + self.state + .playlists + .get(playlist_index) + .and_then(|playlist| { + playlist + .tracks + .iter() + .position(|track| same_path(&track.path, &active_path)) + .map(|track_index| (playlist_index, track_index)) + }) }) .or_else(|| self.find_track_location(&active_path)); @@ -394,9 +505,14 @@ impl AudioOrbitApp { if ui .add_enabled( scan_idle, - egui::Button::new(ui_icons::label(Icon::RefreshCw, "Sync selected playlist now")), + egui::Button::new(ui_icons::label( + Icon::RefreshCw, + "Sync selected playlist now", + )), + ) + .on_hover_text( + "Check only selected playlist. Folder playlists scan their source folder once.", ) - .on_hover_text("Check only selected playlist. Folder playlists scan their source folder once.") .clicked() { self.start_library_sync(LibrarySyncTrigger::Manual, true); @@ -413,20 +529,32 @@ impl AudioOrbitApp { ui.label("Radio recording folder"); ui.horizontal_wrapped(|ui| { ui.monospace(folder.display().to_string()); - if ui.button(ui_icons::label(Icon::FolderOpen, "Choose folder...")).clicked() { + if ui + .button(ui_icons::label(Icon::FolderOpen, "Choose folder...")) + .clicked() + { self.choose_recording_folder(); } - if ui.button(ui_icons::label(Icon::ExternalLink, "Open current folder")).clicked() { + if ui + .button(ui_icons::label(Icon::ExternalLink, "Open current folder")) + .clicked() + { self.open_recording_folder(); } if ui.button("Reset default").clicked() { self.state.recording.output_folder = None; - self.status_message = "Radio recording folder reset to .audio-orbit-records next to the executable.".to_owned(); + self.status_message = + "Radio recording folder reset to .audio-orbit-records next to the executable." + .to_owned(); self.error_message = None; self.save_state_silently(); } }); - if let Some(info) = self.player.as_ref().and_then(|player| player.radio_recording_info()) { + if let Some(info) = self + .player + .as_ref() + .and_then(|player| player.radio_recording_info()) + { ui.colored_label( egui::Color32::RED, format!( @@ -473,7 +601,9 @@ impl AudioOrbitApp { .changed(); playback_changed |= ui .checkbox(&mut self.state.playback.shuffle_enabled, "Shuffle playback") - .on_hover_text("Randomizes the next track inside the current playlist or repeat selection.") + .on_hover_text( + "Randomizes the next track inside the current playlist or repeat selection.", + ) .changed(); ui.horizontal(|ui| { @@ -497,18 +627,34 @@ impl AudioOrbitApp { .selected_text(self.state.playback.repeat_mode.label()) .show_ui(ui, |ui| { playback_changed |= ui - .selectable_value(&mut self.state.playback.repeat_mode, RepeatMode::Off, RepeatMode::Off.label()) + .selectable_value( + &mut self.state.playback.repeat_mode, + RepeatMode::Off, + RepeatMode::Off.label(), + ) .changed(); playback_changed |= ui - .selectable_value(&mut self.state.playback.repeat_mode, RepeatMode::Track, RepeatMode::Track.label()) + .selectable_value( + &mut self.state.playback.repeat_mode, + RepeatMode::Track, + RepeatMode::Track.label(), + ) .changed(); playback_changed |= ui - .selectable_value(&mut self.state.playback.repeat_mode, RepeatMode::Selection, RepeatMode::Selection.label()) + .selectable_value( + &mut self.state.playback.repeat_mode, + RepeatMode::Selection, + RepeatMode::Selection.label(), + ) .changed(); }); }); if self.state.playback.repeat_mode == RepeatMode::Selection { - let repeat_order = if self.state.playback.shuffle_enabled { "at random" } else { "in playlist order" }; + let repeat_order = if self.state.playback.shuffle_enabled { + "at random" + } else { + "in playlist order" + }; ui.small(format!( "{} selected track(s) will repeat {repeat_order}.", self.selected_track_indexes.len() @@ -516,7 +662,10 @@ impl AudioOrbitApp { } playback_changed |= ui - .checkbox(&mut self.state.playback.crossfade_enabled, "Crossfade source changes") + .checkbox( + &mut self.state.playback.crossfade_enabled, + "Crossfade source changes", + ) .changed(); if self.state.playback.crossfade_enabled { playback_changed |= ui @@ -651,17 +800,28 @@ impl AudioOrbitApp { ui.label(format!("Current version: v{}", check.current_version)); ui.label(format!( "Latest {} version: v{}", - if check.prerelease { "prerelease" } else { "stable" }, + if check.prerelease { + "prerelease" + } else { + "stable" + }, check.latest_version )); ui.label(format!( "Release type: {}", - if check.prerelease { "prerelease" } else { "stable" } + if check.prerelease { + "prerelease" + } else { + "stable" + } )); if let Some(asset_name) = &check.asset_name { ui.label(format!("Asset: {asset_name}")); } else { - ui.colored_label(egui::Color32::YELLOW, "No Windows executable asset was found for this release."); + ui.colored_label( + egui::Color32::YELLOW, + "No Windows executable asset was found for this release.", + ); } if check.is_update_available { @@ -669,7 +829,11 @@ impl AudioOrbitApp { egui::Color32::LIGHT_GREEN, format!( "A newer Audio Orbit {} release is available.", - if check.prerelease { "prerelease" } else { "stable" } + if check.prerelease { + "prerelease" + } else { + "stable" + } ), ); if ui @@ -687,7 +851,11 @@ impl AudioOrbitApp { egui::Color32::LIGHT_GREEN, format!( "No newer {} release is available.", - if check.prerelease { "prerelease" } else { "stable" } + if check.prerelease { + "prerelease" + } else { + "stable" + } ), ); } @@ -695,23 +863,33 @@ impl AudioOrbitApp { ui.small("No update check result yet."); } } - pub(crate) fn render_backup_settings_section_inner(&mut self, ui: &mut egui::Ui, show_title: bool) { + pub(crate) fn render_backup_settings_section_inner( + &mut self, + ui: &mut egui::Ui, + show_title: bool, + ) { if show_title { ui.heading("Backup and data"); } ui.small("The ZIP backup stores the full app state: music folders, playlists, Favorites, sound profiles, playback settings, recording settings, and UI settings."); ui.horizontal_wrapped(|ui| { - if ui.button(ui_icons::label(Icon::Download, "Export full backup ZIP")).clicked() { + if ui + .button(ui_icons::label(Icon::Download, "Export full backup ZIP")) + .clicked() + { self.export_app_backup(); } - if ui.button(ui_icons::label(Icon::Upload, "Import backup ZIP")).clicked() { + if ui + .button(ui_icons::label(Icon::Upload, "Import backup ZIP")) + .clicked() + { self.import_app_backup(); } }); if let Some(path) = app_data_dir() { - ui.small(format!("Portable data folder: {}", path.display())); + ui.small(format!("Data folder: {}", path.display())); } } pub(crate) fn render_about_section_inner(&mut self, ui: &mut egui::Ui, show_title: bool) { @@ -722,8 +900,15 @@ impl AudioOrbitApp { ui.add_space(8.0); ui.add(egui::Label::new(format!("Version: {}", app_version_label())).wrap()); ui.add(egui::Label::new("Creator: Zoltán Rózsa").wrap()); - ui.add(egui::Label::new("License: GNU Affero General Public License v3.0 (AGPL-3.0)").wrap()); - ui.add(egui::Label::new("This app stores its portable state next to the executable in .audio-orbit-data.").wrap()); + ui.add( + egui::Label::new("License: GNU Affero General Public License v3.0 (AGPL-3.0)").wrap(), + ); + ui.add( + egui::Label::new( + "Release builds store portable state next to the executable. Development runs use a stable project-local data folder.", + ) + .wrap(), + ); ui.add_space(10.0); ui.heading("External components"); @@ -749,7 +934,12 @@ impl AudioOrbitApp { pub(crate) fn modal_info_footer_reserved_height(&self) -> f32 { 24.0 } - pub(crate) fn render_modal_info_footer_fixed(&self, context: &egui::Context, id: &'static str, modal_rect: egui::Rect) { + pub(crate) fn render_modal_info_footer_fixed( + &self, + context: &egui::Context, + id: &'static str, + modal_rect: egui::Rect, + ) { let footer_height = self.modal_info_footer_reserved_height(); let top_left = egui::pos2(modal_rect.left(), modal_rect.bottom() - footer_height); let style = context.style(); @@ -766,9 +956,13 @@ impl AudioOrbitApp { .fill(footer_fill) .stroke(footer_stroke) .corner_radius(egui::CornerRadius::same(0)) - .inner_margin(egui::Margin::symmetric(horizontal_padding as i8, vertical_padding as i8)) + .inner_margin(egui::Margin::symmetric( + horizontal_padding as i8, + vertical_padding as i8, + )) .show(ui, |ui| { - let content_width = (modal_rect.width() - horizontal_padding * 2.0).max(180.0); + let content_width = + (modal_rect.width() - horizontal_padding * 2.0).max(180.0); let content_height = (footer_height - vertical_padding * 2.0).max(18.0); ui.set_min_size(egui::vec2(content_width, content_height)); ui.set_width(content_width); diff --git a/src/app/ui_player.rs b/src/app/ui_player.rs index 8747932..b5d7585 100644 --- a/src/app/ui_player.rs +++ b/src/app/ui_player.rs @@ -22,7 +22,8 @@ impl AudioOrbitApp { if ui .add_enabled( self.player.is_some(), - egui::Button::new(self.control_label(Icon::SkipBack, "Previous")).min_size(transport_button_size), + egui::Button::new(self.control_label(Icon::SkipBack, "Previous")) + .min_size(transport_button_size), ) .clicked() { @@ -51,7 +52,8 @@ impl AudioOrbitApp { if ui .add_enabled( self.player.is_some(), - egui::Button::new(self.control_label(Icon::Square, "Stop")).min_size(stop_button_size), + egui::Button::new(self.control_label(Icon::Square, "Stop")) + .min_size(stop_button_size), ) .clicked() { @@ -62,7 +64,8 @@ impl AudioOrbitApp { if ui .add_enabled( self.player.is_some(), - egui::Button::new(self.control_label(Icon::SkipForward, "Next")).min_size(transport_button_size), + egui::Button::new(self.control_label(Icon::SkipForward, "Next")) + .min_size(transport_button_size), ) .clicked() { @@ -81,7 +84,11 @@ impl AudioOrbitApp { return; } - let is_recording = self.player.as_ref().map(|player| player.is_radio_recording()).unwrap_or(false); + let is_recording = self + .player + .as_ref() + .map(|player| player.is_radio_recording()) + .unwrap_or(false); let blink_on = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| (duration.as_millis() / 500) % 2 == 0) @@ -103,13 +110,13 @@ impl AudioOrbitApp { } else { egui::Button::new(record_text).min_size(icon_button_size) }; - let record_response = ui - .add_sized(icon_button_size, record_button) - .on_hover_text(if is_recording { + let record_response = ui.add_sized(icon_button_size, record_button).on_hover_text( + if is_recording { "Stop and save radio recording · Right-click to open the recordings folder" } else { "Record original internet radio stream · Right-click to open the recordings folder" - }); + }, + ); if record_response.clicked() { self.toggle_radio_recording(); } @@ -119,7 +126,8 @@ impl AudioOrbitApp { } pub(crate) fn copy_current_radio_title(&mut self) { let Some(radio_index) = self.active_radio_index else { - self.error_message = Some("Start an internet radio station before copying track info.".to_owned()); + self.error_message = + Some("Start an internet radio station before copying track info.".to_owned()); return; }; @@ -132,16 +140,29 @@ impl AudioOrbitApp { .clone() .filter(|name| !name.trim().is_empty()) }) - .or_else(|| self.state.radio_stations.get(radio_index).map(|station| station.name.clone())) + .or_else(|| { + self.state + .radio_stations + .get(radio_index) + .map(|station| station.name.clone()) + }) .unwrap_or_else(|| "Internet radio".to_owned()); self.pending_clipboard_text = Some(text.clone()); self.status_message = format!("Radio info copied: {text}."); self.error_message = None; } - pub(crate) fn render_copy_and_volume_controls(&mut self, ui: &mut egui::Ui, _has_now_playing: bool, icon_button_size: egui::Vec2) { + pub(crate) fn render_copy_and_volume_controls( + &mut self, + ui: &mut egui::Ui, + _has_now_playing: bool, + icon_button_size: egui::Vec2, + ) { if self.active_radio_index.is_some() { - let copy_button_size = egui::vec2(if self.player_only_mode { 42.0 } else { 58.0 }, icon_button_size.y); + let copy_button_size = egui::vec2( + if self.player_only_mode { 42.0 } else { 58.0 }, + icon_button_size.y, + ); if ui .add_sized(copy_button_size, egui::Button::new("Copy")) .on_hover_text("Copy the current radio stream title. Falls back to the station name when no title is available.") @@ -151,9 +172,16 @@ impl AudioOrbitApp { } } - let volume_icon = if self.effective_volume_percent() == 0 { Icon::VolumeX } else { Icon::Volume2 }; + let volume_icon = if self.effective_volume_percent() == 0 { + Icon::VolumeX + } else { + Icon::Volume2 + }; if ui - .add_sized(icon_button_size, egui::Button::new(egui::RichText::new(ui_icons::icon(volume_icon)).size(14.0))) + .add_sized( + icon_button_size, + egui::Button::new(egui::RichText::new(ui_icons::icon(volume_icon)).size(14.0)), + ) .on_hover_text("Mute / unmute") .clicked() { @@ -183,41 +211,108 @@ impl AudioOrbitApp { ui.horizontal(|ui| { let controls_width = if self.player_only_mode { 74.0 } else { 164.0 }; let title_width = (ui.available_width() - controls_width).max(140.0); - let (title_rect, title_response) = ui.allocate_exact_size( - egui::vec2(title_width, 54.0), - egui::Sense::hover(), - ); + let (title_rect, title_response) = + ui.allocate_exact_size(egui::vec2(title_width, 54.0), egui::Sense::hover()); let title_available_width = title_width - 10.0; let active_title_color = ui.visuals().widgets.inactive.fg_stroke.color; - let active_detail_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.76); - let active_time_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.68); - let placeholder_title_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.82); - let placeholder_detail_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.62); - let placeholder_time_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.52); + let active_detail_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.76); + let active_time_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.68); + let placeholder_title_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.82); + let placeholder_detail_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.62); + let placeholder_time_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.52); let title_font = egui::FontId::proportional(15.0); let detail_font = egui::FontId::proportional(11.5); let time_font = egui::FontId::proportional(11.5); - let (title, detail, time_label, title_color, detail_color, time_color) = if has_now_playing { - ( - ellipsize_to_width_exact(ui, &self.active_track_title(), title_available_width, title_font.clone(), active_title_color), - self.active_track_detail() - .map(|value| ellipsize_to_width_exact(ui, &value, title_available_width, detail_font.clone(), active_detail_color)) - .unwrap_or_default(), - ellipsize_to_width_exact(ui, &self.active_track_time_label(), title_available_width, time_font.clone(), active_time_color), - active_title_color, - active_detail_color, - active_time_color, - ) - } else { - ( - ellipsize_to_width_exact(ui, "Audio Orbit is ready", title_available_width, title_font.clone(), placeholder_title_color), - ellipsize_to_width_exact(ui, "Choose a song, start a playlist, or tune in to internet radio.", title_available_width, detail_font.clone(), placeholder_detail_color), - ellipsize_to_width_exact(ui, "Local music · Live radio · Sound profiles", title_available_width, time_font.clone(), placeholder_time_color), - placeholder_title_color, - placeholder_detail_color, - placeholder_time_color, - ) - }; + let (title, detail, time_label, title_color, detail_color, time_color) = + if has_now_playing { + ( + ellipsize_to_width_exact( + ui, + &self.active_track_title(), + title_available_width, + title_font.clone(), + active_title_color, + ), + self.active_track_detail() + .map(|value| { + ellipsize_to_width_exact( + ui, + &value, + title_available_width, + detail_font.clone(), + active_detail_color, + ) + }) + .unwrap_or_default(), + ellipsize_to_width_exact( + ui, + &self.active_track_time_label(), + title_available_width, + time_font.clone(), + active_time_color, + ), + active_title_color, + active_detail_color, + active_time_color, + ) + } else { + ( + ellipsize_to_width_exact( + ui, + "Audio Orbit is ready", + title_available_width, + title_font.clone(), + placeholder_title_color, + ), + ellipsize_to_width_exact( + ui, + "Choose a song, start a playlist, or tune in to internet radio.", + title_available_width, + detail_font.clone(), + placeholder_detail_color, + ), + ellipsize_to_width_exact( + ui, + "Local music · Live radio · Sound profiles", + title_available_width, + time_font.clone(), + placeholder_time_color, + ), + placeholder_title_color, + placeholder_detail_color, + placeholder_time_color, + ) + }; let painter = ui.painter(); painter.text( @@ -249,7 +344,11 @@ impl AudioOrbitApp { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let settings_label = self.control_label(Icon::Settings2, "Settings"); - if ui.button(settings_label).on_hover_text("Settings").clicked() { + if ui + .button(settings_label) + .on_hover_text("Settings") + .clicked() + { self.open_panel_modal(AppPanelModal::Settings); } @@ -285,7 +384,6 @@ impl AudioOrbitApp { self.save_state_silently(); } } - }); }); @@ -295,7 +393,8 @@ impl AudioOrbitApp { .clamp(1.0, RADIO_WAVEFORM_MAX_VISIBLE_SECONDS); let requested_points = (available_width / RADIO_WAVEFORM_BAR_PITCH_PIXELS) .floor() - .clamp(1.0, 900.0) as usize + 1; + .clamp(1.0, 900.0) as usize + + 1; let frame = self .player .as_ref() @@ -349,7 +448,10 @@ impl AudioOrbitApp { let pointer_position = response.interact_pointer_pos(); if response.drag_started() || response.dragged() { if let Some(pointer) = pointer_position { - let next_position = ((pointer.x - response.rect.left()) / response.rect.width()).clamp(0.0, 1.0) * duration; + let next_position = ((pointer.x - response.rect.left()) + / response.rect.width()) + .clamp(0.0, 1.0) + * duration; self.waveform_drag_position_seconds = Some(next_position); } } @@ -357,7 +459,9 @@ impl AudioOrbitApp { if response.drag_stopped() { let next_position = self.waveform_drag_position_seconds.or_else(|| { pointer_position.map(|pointer| { - ((pointer.x - response.rect.left()) / response.rect.width()).clamp(0.0, 1.0) * duration + ((pointer.x - response.rect.left()) / response.rect.width()) + .clamp(0.0, 1.0) + * duration }) }); if let Some(next_position) = next_position { @@ -366,7 +470,10 @@ impl AudioOrbitApp { self.waveform_drag_position_seconds = None; } else if response.clicked() { if let Some(pointer) = pointer_position { - let next_position = ((pointer.x - response.rect.left()) / response.rect.width()).clamp(0.0, 1.0) * duration; + let next_position = ((pointer.x - response.rect.left()) + / response.rect.width()) + .clamp(0.0, 1.0) + * duration; self.seek_current(next_position); } self.waveform_drag_position_seconds = None; @@ -394,15 +501,31 @@ impl AudioOrbitApp { .unwrap_or(false); let play_label = match self.player.as_ref() { - Some(player) if context_is_active && player.is_playing() => self.control_label(Icon::Pause, "Pause"), - Some(player) if context_is_active && player.is_paused() => self.control_label(Icon::Play, "Resume"), + Some(player) if context_is_active && player.is_playing() => { + self.control_label(Icon::Pause, "Pause") + } + Some(player) if context_is_active && player.is_paused() => { + self.control_label(Icon::Play, "Resume") + } _ => self.control_label(Icon::Play, "Play"), }; let icon_button_size = egui::vec2(24.0, 24.0); - let transport_button_size = if self.player_only_mode { icon_button_size } else { egui::vec2(84.0, 24.0) }; - let play_button_size = if self.player_only_mode { icon_button_size } else { egui::vec2(70.0, 24.0) }; - let stop_button_size = if self.player_only_mode { icon_button_size } else { egui::vec2(64.0, 24.0) }; + let transport_button_size = if self.player_only_mode { + icon_button_size + } else { + egui::vec2(84.0, 24.0) + }; + let play_button_size = if self.player_only_mode { + icon_button_size + } else { + egui::vec2(70.0, 24.0) + }; + let stop_button_size = if self.player_only_mode { + icon_button_size + } else { + egui::vec2(64.0, 24.0) + }; let control_width = ui.available_width(); if control_width < 360.0 { @@ -419,7 +542,11 @@ impl AudioOrbitApp { ); }); ui.horizontal(|ui| { - self.render_playback_mode_or_recording_controls(ui, radio_controls_active, icon_button_size); + self.render_playback_mode_or_recording_controls( + ui, + radio_controls_active, + icon_button_size, + ); }); ui.horizontal(|ui| { self.render_copy_and_volume_controls(ui, has_now_playing, icon_button_size); @@ -437,7 +564,11 @@ impl AudioOrbitApp { play_button_size, stop_button_size, ); - self.render_playback_mode_or_recording_controls(ui, radio_controls_active, icon_button_size); + self.render_playback_mode_or_recording_controls( + ui, + radio_controls_active, + icon_button_size, + ); }); ui.horizontal(|ui| { self.render_copy_and_volume_controls(ui, has_now_playing, icon_button_size); @@ -454,7 +585,11 @@ impl AudioOrbitApp { play_button_size, stop_button_size, ); - self.render_playback_mode_or_recording_controls(ui, radio_controls_active, icon_button_size); + self.render_playback_mode_or_recording_controls( + ui, + radio_controls_active, + icon_button_size, + ); self.render_copy_and_volume_controls(ui, has_now_playing, icon_button_size); }); } @@ -465,7 +600,10 @@ impl AudioOrbitApp { egui::Color32::YELLOW, format!("Output changed to {output_name}."), ); - if ui.button(ui_icons::label(Icon::RefreshCw, "Refresh and continue")).clicked() { + if ui + .button(ui_icons::label(Icon::RefreshCw, "Refresh and continue")) + .clicked() + { self.refresh_output_device(); } }); @@ -490,14 +628,24 @@ impl AudioOrbitApp { }; if ui .add(repeat_button) - .on_hover_text(format!("Cycle repeat mode. Current: {}", self.state.playback.repeat_mode.label())) + .on_hover_text(format!( + "Cycle repeat mode. Current: {}", + self.state.playback.repeat_mode.label() + )) .clicked() { self.state.playback.repeat_mode = self.state.playback.repeat_mode.next(); playback_changed = true; } playback_changed |= ui - .checkbox(&mut self.state.playback.auto_advance, if self.player_only_mode { "Auto" } else { "Auto-play next" }) + .checkbox( + &mut self.state.playback.auto_advance, + if self.player_only_mode { + "Auto" + } else { + "Auto-play next" + }, + ) .changed(); playback_changed |= ui .checkbox(&mut self.state.playback.shuffle_enabled, "Shuffle") @@ -506,6 +654,5 @@ impl AudioOrbitApp { if playback_changed { self.save_state_silently(); } - } } diff --git a/src/app/ui_profiles.rs b/src/app/ui_profiles.rs index 1dce55c..6e3b783 100644 --- a/src/app/ui_profiles.rs +++ b/src/app/ui_profiles.rs @@ -36,7 +36,10 @@ impl AudioOrbitApp { } ui.horizontal_wrapped(|ui| { - if ui.button(ui_icons::label(Icon::Plus, "New profile")).clicked() { + if ui + .button(ui_icons::label(Icon::Plus, "New profile")) + .clicked() + { self.add_profile(); } @@ -44,7 +47,11 @@ impl AudioOrbitApp { self.remove_current_profile(); } - if ui.small_button(ui_icons::icon(Icon::Pencil)).on_hover_text("Rename profile").clicked() { + if ui + .small_button(ui_icons::icon(Icon::Pencil)) + .on_hover_text("Rename profile") + .clicked() + { self.editing_profile_index = Some(self.state.selected_profile_index); } }); @@ -52,7 +59,11 @@ impl AudioOrbitApp { ui.add_space(8.0); let mut profile_changed = false; - if let Some(profile) = self.state.profiles.get_mut(self.state.selected_profile_index) { + if let Some(profile) = self + .state + .profiles + .get_mut(self.state.selected_profile_index) + { if self.editing_profile_index == Some(self.state.selected_profile_index) { ui.label("Profile name"); profile_changed |= ui.text_edit_singleline(&mut profile.name).changed(); @@ -105,8 +116,11 @@ impl AudioOrbitApp { .changed(); profile_changed |= ui .add( - egui::Slider::new(&mut profile.settings.transition_smoothness_percent, 0u8..=100u8) - .text("Motion Smoothness (%)"), + egui::Slider::new( + &mut profile.settings.transition_smoothness_percent, + 0u8..=100u8, + ) + .text("Motion Smoothness (%)"), ) .changed(); profile_changed |= ui @@ -116,7 +130,6 @@ impl AudioOrbitApp { ) .changed(); }); - } if profile_changed { @@ -131,7 +144,11 @@ impl AudioOrbitApp { ui.add_space(12.0); ui.separator(); ui.horizontal_wrapped(|ui| { - if ui.small_button(ui_icons::icon(Icon::RefreshCw)).on_hover_text("Refresh output device").clicked() { + if ui + .small_button(ui_icons::icon(Icon::RefreshCw)) + .on_hover_text("Refresh output device") + .clicked() + { self.refresh_output_device(); } ui.label(self.last_known_output_name.as_str()); @@ -144,7 +161,10 @@ impl AudioOrbitApp { let mut playback_changed = false; playback_changed |= ui - .checkbox(&mut self.state.playback.crossfade_enabled, "Crossfade source changes") + .checkbox( + &mut self.state.playback.crossfade_enabled, + "Crossfade source changes", + ) .changed(); if self.state.playback.crossfade_enabled { playback_changed |= ui diff --git a/src/app/ui_radio.rs b/src/app/ui_radio.rs index 9ef5df5..2aa31c8 100644 --- a/src/app/ui_radio.rs +++ b/src/app/ui_radio.rs @@ -15,10 +15,18 @@ impl AudioOrbitApp { self.show_radio_add_modal = true; } - let search_icon = if self.show_radio_search { Icon::X } else { Icon::Search }; + let search_icon = if self.show_radio_search { + Icon::X + } else { + Icon::Search + }; if ui .button(ui_icons::icon(search_icon)) - .on_hover_text(if self.show_radio_search { "Hide radio search" } else { "Search radio stations" }) + .on_hover_text(if self.show_radio_search { + "Hide radio search" + } else { + "Search radio stations" + }) .clicked() { self.show_radio_search = !self.show_radio_search; @@ -29,7 +37,10 @@ impl AudioOrbitApp { } if ui - .add_enabled(self.active_radio_index.is_some(), egui::Button::new(ui_icons::label(Icon::Music, "Now playing"))) + .add_enabled( + self.active_radio_index.is_some(), + egui::Button::new(ui_icons::label(Icon::Music, "Now playing")), + ) .on_hover_text("Center the currently playing radio station") .clicked() { @@ -42,14 +53,25 @@ impl AudioOrbitApp { ui.horizontal(|ui| { if ui - .selectable_label(!self.radio_show_favorites_only, format!("All ({})", self.state.radio_stations.len())) + .selectable_label( + !self.radio_show_favorites_only, + format!("All ({})", self.state.radio_stations.len()), + ) .clicked() { self.radio_show_favorites_only = false; } - let favorite_count = self.state.radio_stations.iter().filter(|station| station.favorite).count(); + let favorite_count = self + .state + .radio_stations + .iter() + .filter(|station| station.favorite) + .count(); if ui - .selectable_label(self.radio_show_favorites_only, format!("Favorites ({favorite_count})")) + .selectable_label( + self.radio_show_favorites_only, + format!("Favorites ({favorite_count})"), + ) .clicked() { self.radio_show_favorites_only = true; @@ -57,10 +79,18 @@ impl AudioOrbitApp { }); ui.horizontal(|ui| { - if ui.small_button("A-Z").on_hover_text("Sort radio stations A to Z").clicked() { + if ui + .small_button("A-Z") + .on_hover_text("Sort radio stations A to Z") + .clicked() + { self.sort_radio_stations_by_name(true); } - if ui.small_button("Z-A").on_hover_text("Sort radio stations Z to A").clicked() { + if ui + .small_button("Z-A") + .on_hover_text("Sort radio stations Z to A") + .clicked() + { self.sort_radio_stations_by_name(false); } }); @@ -70,7 +100,8 @@ impl AudioOrbitApp { ui.label(ui_icons::icon(Icon::Search)); let response = ui.add_sized( egui::vec2((ui.available_width() - 92.0).max(180.0), 22.0), - egui::TextEdit::singleline(&mut self.radio_search_query).hint_text("Search by station name, URL, or stream title"), + egui::TextEdit::singleline(&mut self.radio_search_query) + .hint_text("Search by station name, URL, or stream title"), ); if self.focus_radio_search { response.request_focus(); @@ -147,17 +178,22 @@ impl AudioOrbitApp { ui.set_width(row_width); let visible_station_len = visible_stations.len(); let station_count = self.state.radio_stations.len(); - let pointer_position = ui.input(|input| input.pointer.hover_pos().or(input.pointer.interact_pos())); - for (visible_row_index, (index, station)) in visible_stations.iter().cloned().enumerate() { + let pointer_position = + ui.input(|input| input.pointer.hover_pos().or(input.pointer.interact_pos())); + for (visible_row_index, (index, station)) in + visible_stations.iter().cloned().enumerate() + { let next_visible_station_index = visible_stations .get(visible_row_index + 1) .map(|(next_index, _)| *next_index); let active = self.active_radio_index == Some(index); - let selected = active || (self.radio_selection_was_user_set && self.state.selected_radio_index == Some(index)); - let display_stream_title = station - .last_stream_title - .as_deref() - .filter(|title| !title.trim().is_empty() && !title.eq_ignore_ascii_case(&station.name)); + let selected = active + || (self.radio_selection_was_user_set + && self.state.selected_radio_index == Some(index)); + let display_stream_title = + station.last_stream_title.as_deref().filter(|title| { + !title.trim().is_empty() && !title.eq_ignore_ascii_case(&station.name) + }); let primary_title = display_stream_title.unwrap_or(station.name.as_str()); let station_title = if active { format!("{} {}", ui_icons::icon(Icon::Play), primary_title) @@ -176,7 +212,9 @@ impl AudioOrbitApp { egui::Layout::left_to_right(egui::Align::Center), |ui| { let heart = if station.favorite { - egui::RichText::new("♥").color(egui::Color32::from_rgb(230, 70, 95)).size(15.0) + egui::RichText::new("♥") + .color(egui::Color32::from_rgb(230, 70, 95)) + .size(15.0) } else { egui::RichText::new("♡").size(15.0) }; @@ -195,7 +233,11 @@ impl AudioOrbitApp { ); if selected { - ui.painter().rect_filled(body_rect, 5.0, ui.visuals().selection.bg_fill); + ui.painter().rect_filled( + body_rect, + 5.0, + ui.visuals().selection.bg_fill, + ); } let body_padding = 8.0; @@ -211,7 +253,12 @@ impl AudioOrbitApp { let info_color = if active || row_hovered { ui.visuals().widgets.inactive.fg_stroke.color } else { - ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.50) + ui.visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.50) }; let info_width = if station_info.is_empty() { 0.0 @@ -220,14 +267,21 @@ impl AudioOrbitApp { }; let info_gap = if station_info.is_empty() { 0.0 } else { 6.0 }; let title_left = body_rect.left() + body_padding; - let title_right = (body_rect.right() - body_padding - info_width - info_gap) - .max(title_left + 24.0); + let title_right = + (body_rect.right() - body_padding - info_width - info_gap) + .max(title_left + 24.0); let title_rect = egui::Rect::from_min_max( egui::pos2(title_left, body_rect.top()), egui::pos2(title_right, body_rect.bottom()), ); - let station_title = ellipsize_to_width_exact(ui, &station_title, title_rect.width(), title_font.clone(), text_color); + let station_title = ellipsize_to_width_exact( + ui, + &station_title, + title_rect.width(), + title_font.clone(), + text_color, + ); ui.painter().with_clip_rect(title_rect).text( egui::pos2(title_rect.left(), title_rect.center().y), egui::Align2::LEFT_CENTER, @@ -238,8 +292,14 @@ impl AudioOrbitApp { if info_width > 0.0 { let info_rect = egui::Rect::from_min_max( - egui::pos2(body_rect.right() - body_padding - info_width, body_rect.top()), - egui::pos2(body_rect.right() - body_padding, body_rect.bottom()), + egui::pos2( + body_rect.right() - body_padding - info_width, + body_rect.top(), + ), + egui::pos2( + body_rect.right() - body_padding, + body_rect.bottom(), + ), ); ui.painter().with_clip_rect(info_rect).text( egui::pos2(info_rect.right(), info_rect.center().y), @@ -296,7 +356,9 @@ impl AudioOrbitApp { index }; next_radio_drop_target_index = Some(to); - if ui.input(|input| input.pointer.any_released()) && Self::valid_drop_target(from, to) { + if ui.input(|input| input.pointer.any_released()) + && Self::valid_drop_target(from, to) + { reorder_radio_station = Some((from, to)); self.dragging_radio_index = None; } @@ -304,20 +366,31 @@ impl AudioOrbitApp { } let radio_drop_target_for_paint = next_radio_drop_target_index .or(self.radio_drop_target_index) - .filter(|to| self.dragging_radio_index.map(|from| Self::valid_drop_target(from, *to)).unwrap_or(false)); - if self.dragging_radio_index == Some(index) && radio_drop_target_for_paint.is_some() { + .filter(|to| { + self.dragging_radio_index + .map(|from| Self::valid_drop_target(from, *to)) + .unwrap_or(false) + }); + if self.dragging_radio_index == Some(index) + && radio_drop_target_for_paint.is_some() + { paint_dragged_row_fade(ui, row_response.response.rect); } if visible_row_index == 0 && radio_drop_target_for_paint == Some(index) { paint_list_edge_separator(ui, row_response.response.rect, row_width, false); } - if row_response.response.secondary_clicked() || context_response.secondary_clicked() { + if row_response.response.secondary_clicked() + || context_response.secondary_clicked() + { self.state.selected_radio_index = Some(index); self.radio_selection_was_user_set = true; self.save_state_silently(); } context_response.context_menu(|ui| { - if ui.button(ui_icons::label(Icon::Play, "Play station")).clicked() { + if ui + .button(ui_icons::label(Icon::Play, "Play station")) + .clicked() + { play_radio_index = Some(index); ui.close_menu(); } @@ -333,18 +406,27 @@ impl AudioOrbitApp { self.move_radio_station(index, 1); ui.close_menu(); } - if ui.button(ui_icons::label(Icon::Trash2, "Remove station")).clicked() { + if ui + .button(ui_icons::label(Icon::Trash2, "Remove station")) + .clicked() + { remove_radio_index = Some(index); ui.close_menu(); } }); if self.scroll_to_active_radio_requested && active { - row_response.response.scroll_to_me(Some(egui::Align::Center)); + row_response + .response + .scroll_to_me(Some(egui::Align::Center)); self.scroll_to_active_radio_requested = false; } if visible_row_index + 1 < visible_station_len { let separator_drop_target = next_visible_station_index.unwrap_or(index + 1); - paint_list_separator(ui, row_width, radio_drop_target_for_paint == Some(separator_drop_target)); + paint_list_separator( + ui, + row_width, + radio_drop_target_for_paint == Some(separator_drop_target), + ); } else if radio_drop_target_for_paint == Some(station_count) { paint_list_edge_separator(ui, row_response.response.rect, row_width, true); } diff --git a/src/app/ui_status.rs b/src/app/ui_status.rs index 6310c88..fa90149 100644 --- a/src/app/ui_status.rs +++ b/src/app/ui_status.rs @@ -11,7 +11,8 @@ impl AudioOrbitApp { return None; } let visible = self.visible_track_indexes().len(); - if self.show_track_search && !self.track_search_query.trim().is_empty() && visible != total { + if self.show_track_search && !self.track_search_query.trim().is_empty() && visible != total + { Some(format!("{visible}/{total} tracks")) } else { Some(format!("{total} tracks")) @@ -41,24 +42,47 @@ impl AudioOrbitApp { .profile_apply_status_text() .unwrap_or_else(|| self.status_message.clone()); let status_message = if include_error { - self.error_message.as_deref().unwrap_or(primary_status.as_str()) + self.error_message + .as_deref() + .unwrap_or(primary_status.as_str()) } else { primary_status.as_str() }; - let status_color = if include_error && self.error_message.is_some() { error_color } else { text_color }; - let separator_width = if !status_message.is_empty() && !self.media_key_status.is_empty() { 12.0 } else { 0.0 }; - let available_status_width = (available_width - count_width - media_width - separator_width - 12.0).max(48.0); + let status_color = if include_error && self.error_message.is_some() { + error_color + } else { + text_color + }; + let separator_width = if !status_message.is_empty() && !self.media_key_status.is_empty() { + 12.0 + } else { + 0.0 + }; + let available_status_width = + (available_width - count_width - media_width - separator_width - 12.0).max(48.0); ui.horizontal(|ui| { if !status_message.is_empty() { - render_ellipsized_single_line(ui, status_message, available_status_width, body_font.clone(), status_color); + render_ellipsized_single_line( + ui, + status_message, + available_status_width, + body_font.clone(), + status_color, + ); if !self.media_key_status.is_empty() { ui.separator(); } } if !self.media_key_status.is_empty() { let width = media_width.max(48.0); - render_ellipsized_single_line(ui, &self.media_key_status, width, small_font.clone(), text_color); + render_ellipsized_single_line( + ui, + &self.media_key_status, + width, + small_font.clone(), + text_color, + ); } if let Some(count_label) = count_label { @@ -79,9 +103,12 @@ impl AudioOrbitApp { }; let screen_rect = context.screen_rect(); - let estimated_width = (error_message.chars().count() as f32 * 7.0 + 34.0).clamp(260.0, 720.0); + let estimated_width = + (error_message.chars().count() as f32 * 7.0 + 34.0).clamp(260.0, 720.0); let width = estimated_width.min((screen_rect.width() - 32.0).max(260.0)); - let estimated_lines = (error_message.chars().count() as f32 / 90.0).ceil().max(1.0); + let estimated_lines = (error_message.chars().count() as f32 / 90.0) + .ceil() + .max(1.0); let max_height = (estimated_lines * 18.0 + 16.0).clamp(32.0, 112.0); egui::Area::new(egui::Id::new("error_toast_overlay")) .order(egui::Order::Tooltip) @@ -100,7 +127,13 @@ impl AudioOrbitApp { .auto_shrink([false, true]) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.add(egui::Label::new(egui::RichText::new(error_message.as_str()).color(egui::Color32::from_rgb(255, 112, 112))).wrap()); + ui.add( + egui::Label::new( + egui::RichText::new(error_message.as_str()) + .color(egui::Color32::from_rgb(255, 112, 112)), + ) + .wrap(), + ); }); }); }); diff --git a/src/app/ui_tracks.rs b/src/app/ui_tracks.rs index 15c8ed1..7fb0fcb 100644 --- a/src/app/ui_tracks.rs +++ b/src/app/ui_tracks.rs @@ -4,7 +4,8 @@ const TRACK_ROW_HEIGHT: f32 = 32.0; const TRACK_SEPARATOR_HEIGHT: f32 = 1.0; const TRACK_GROUP_TOP_GAP: f32 = 6.0; const TRACK_GROUP_HEADER_HEIGHT: f32 = 24.0; -const TRACK_GROUP_BLOCK_HEIGHT: f32 = TRACK_GROUP_TOP_GAP + TRACK_GROUP_HEADER_HEIGHT + TRACK_SEPARATOR_HEIGHT; +const TRACK_GROUP_BLOCK_HEIGHT: f32 = + TRACK_GROUP_TOP_GAP + TRACK_GROUP_HEADER_HEIGHT + TRACK_SEPARATOR_HEIGHT; const TRACK_LIST_OVERSCAN_ROWS: f32 = 4.0; const PLAYLIST_SUBMENU_MAX_HEIGHT: f32 = 320.0; const PLAYLIST_SUBMENU_GAP: f32 = 1.0; @@ -19,7 +20,9 @@ enum PlaylistSubmenuKind { #[derive(Clone, Debug)] enum PlaylistSubmenuAction { - Add { playlist_index: usize }, + Add { + playlist_index: usize, + }, New, Search { playlist_index: usize, @@ -55,10 +58,7 @@ fn load_playlist_submenu_state(context: &egui::Context) -> Option, -) { +fn store_playlist_submenu_state(context: &egui::Context, state: Option) { context.data_mut(|data| data.insert_temp(playlist_submenu_state_id(), state)); } @@ -88,7 +88,9 @@ fn render_playlist_submenu( .iter() .map(|(label, _)| text_width(ui, label, font_id.clone(), text_color)) .fold(0.0, f32::max); - let content_width = (longest_text_width + button_padding.x * 2.0).ceil().max(1.0); + let content_width = (longest_text_width + button_padding.x * 2.0) + .ceil() + .max(1.0); let outer_width = content_width + frame_margin.left + frame_margin.right; let gap = ui.spacing().menu_spacing.max(PLAYLIST_SUBMENU_GAP); let screen_rect = ui.ctx().screen_rect(); @@ -159,17 +161,24 @@ fn render_playlist_submenu( let (pivot, position) = if open_left { ( egui::Align2::RIGHT_TOP, - egui::pos2(response.rect.left() - gap, response.rect.top() - frame_margin.top), + egui::pos2( + response.rect.left() - gap, + response.rect.top() - frame_margin.top, + ), ) } else { ( egui::Align2::LEFT_TOP, - egui::pos2(response.rect.right() + gap, response.rect.top() - frame_margin.top), + egui::pos2( + response.rect.right() + gap, + response.rect.top() - frame_margin.top, + ), ) }; let available_content_height = - (screen_rect.bottom() - response.rect.top() - frame_margin.bottom).max(ui.spacing().interact_size.y); + (screen_rect.bottom() - response.rect.top() - frame_margin.bottom) + .max(ui.spacing().interact_size.y); let max_height = PLAYLIST_SUBMENU_MAX_HEIGHT.min(available_content_height); let popup_entries = entries; let mut rendered_entries = Vec::with_capacity(popup_entries.len()); @@ -301,8 +310,17 @@ impl AudioOrbitApp { return; }; + let playlist_background = (!self.player_only_mode).then(|| { + ui.interact( + ui.max_rect(), + ui.id().with("playlist_background_context_menu"), + egui::Sense::click(), + ) + }); + let playlist_name = playlist.name.clone(); let is_favorites = playlist.kind == PlaylistKind::Favorites; + let is_temporary = playlist.kind == PlaylistKind::Temporary; let selected_playlist_label = format!("{} {}", playlist.kind.icon(), playlist_name); let selected_group_label = playlist.selected_group.clone().unwrap_or_default(); let folder_group_count = playlist.folder_groups().len(); @@ -316,12 +334,7 @@ impl AudioOrbitApp { .playlists .iter() .enumerate() - .map(|(index, playlist)| { - ( - index, - format!("{} {}", playlist.kind.icon(), playlist.name), - ) - }) + .map(|(index, playlist)| (index, format!("{} {}", playlist.kind.icon(), playlist.name))) .collect(); ui.horizontal(|ui| { @@ -352,14 +365,23 @@ impl AudioOrbitApp { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let search_label = if self.show_track_search { - if compact_controls { ui_icons::icon(Icon::X) } else { self.control_label(Icon::X, "Close search") } + if compact_controls { + ui_icons::icon(Icon::X) + } else { + self.control_label(Icon::X, "Close search") + } } else if compact_controls { ui_icons::icon(Icon::Search) } else { self.control_label(Icon::Search, "Search") }; - if ui.button(search_label) - .on_hover_text(if self.show_track_search { "Close search" } else { "Search tracks" }) + if ui + .button(search_label) + .on_hover_text(if self.show_track_search { + "Close search" + } else { + "Search tracks" + }) .clicked() { self.show_track_search = !self.show_track_search; @@ -372,10 +394,18 @@ impl AudioOrbitApp { self.save_state_silently(); } - if ui.small_button("Z-A").on_hover_text("Sort current playlist Z to A").clicked() { + let sort_descending = ui + .add_enabled_ui(!is_temporary, |ui| ui.small_button("Z-A")) + .inner + .on_hover_text("Sort current playlist Z to A"); + if sort_descending.clicked() { self.sort_current_playlist_by_name(false); } - if ui.small_button("A-Z").on_hover_text("Sort current playlist A to Z").clicked() { + let sort_ascending = ui + .add_enabled_ui(!is_temporary, |ui| ui.small_button("A-Z")) + .inner + .on_hover_text("Sort current playlist A to Z"); + if sort_ascending.clicked() { self.sort_current_playlist_by_name(true); } if is_favorites @@ -387,7 +417,8 @@ impl AudioOrbitApp { self.sort_current_favorites_by_added(); } - let has_active_source = self.active_track_path.is_some() || self.active_radio_index.is_some(); + let has_active_source = + self.active_track_path.is_some() || self.active_radio_index.is_some(); let now_playing_label = if compact_controls { ui_icons::icon(Icon::Music) } else { @@ -395,7 +426,9 @@ impl AudioOrbitApp { }; if ui .add_enabled(has_active_source, egui::Button::new(now_playing_label)) - .on_hover_text("Switch to the active source and center the currently playing item") + .on_hover_text( + "Switch to the active source and center the currently playing item", + ) .clicked() { self.jump_to_now_playing(); @@ -411,7 +444,8 @@ impl AudioOrbitApp { let search_input_width = ui.available_width().max(120.0); let response = ui.add_sized( egui::vec2(search_input_width, ui.spacing().interact_size.y), - egui::TextEdit::singleline(&mut self.track_search_query).hint_text("Search tracks or folders"), + egui::TextEdit::singleline(&mut self.track_search_query) + .hint_text("Search tracks or folders"), ); if self.focus_track_search { response.request_focus(); @@ -449,22 +483,36 @@ impl AudioOrbitApp { }); if !query.is_empty() { ui.add_space(2.0); - let mode = if self.search_playback_filtered_only { "playback is limited to search results" } else { "playback keeps normal playlist order" }; + let mode = if self.search_playback_filtered_only { + "playback is limited to search results" + } else { + "playback keeps normal playlist order" + }; ui.small(format!("Filtering tracks by: {query} · {mode}")); } } if self.state.playback.repeat_mode == RepeatMode::Selection { ui.add_space(4.0); - let repeat_order = if self.state.playback.shuffle_enabled { "random playback" } else { "playlist order" }; - let helper = format!("Repeat selection mode: tick tracks or whole folders for {repeat_order}."); + let repeat_order = if self.state.playback.shuffle_enabled { + "random playback" + } else { + "playlist order" + }; + let helper = + format!("Repeat selection mode: tick tracks or whole folders for {repeat_order}."); let helper_width = ui.available_width().max(32.0); let _ = render_ellipsized_single_line( ui, &helper, helper_width, egui::TextStyle::Small.resolve(ui.style()), - ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.78), + ui.visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.78), ); } @@ -488,6 +536,9 @@ impl AudioOrbitApp { ui.label("No tracks in this view. Add files or import a music folder."); } }); + if let Some(response) = playlist_background { + response.context_menu(|ui| self.render_player_only_playlist_context_menu(ui)); + } return; } @@ -502,7 +553,10 @@ impl AudioOrbitApp { if let Some(playlist) = self.state.playlists.get(playlist_index) { for index in visible_indexes.iter().copied() { if let Some(track) = playlist.tracks.get(index) { - group_track_indexes.entry(track.group.clone()).or_default().push(index); + group_track_indexes + .entry(track.group.clone()) + .or_default() + .push(index); } } } @@ -1097,6 +1151,9 @@ impl AudioOrbitApp { self.track_drop_target_index = None; self.move_track_to_index_in_current_playlist(from, to); } + if let Some(response) = playlist_background { + response.context_menu(|ui| self.render_player_only_playlist_context_menu(ui)); + } } fn process_playlist_submenu_input(&mut self, context: &egui::Context) { let Some(state) = load_playlist_submenu_state(context) else { @@ -1182,6 +1239,11 @@ impl AudioOrbitApp { .unwrap_or(true); let action_paths = self.action_track_paths_for_context(index); let selected_count = action_paths.len(); + let temporary_playback = self + .current_playlist() + .map(|playlist| playlist.kind == PlaylistKind::Temporary) + .unwrap_or(false); + let available_dj_track_count = action_paths.iter().filter(|path| path.is_file()).count(); let file_operation_idle = self.track_file_operations_idle(); if selected_count > 1 { @@ -1205,12 +1267,19 @@ impl AudioOrbitApp { ui.close_menu(); } let can_move_up = selected_count <= 1 && self.can_move_track_in_current_playlist(index, -1); - if ui.add_enabled(can_move_up, egui::Button::new("Move up")).clicked() { + if ui + .add_enabled(can_move_up, egui::Button::new("Move up")) + .clicked() + { self.move_track_in_current_playlist(index, -1); ui.close_menu(); } - let can_move_down = selected_count <= 1 && self.can_move_track_in_current_playlist(index, 1); - if ui.add_enabled(can_move_down, egui::Button::new("Move down")).clicked() { + let can_move_down = + selected_count <= 1 && self.can_move_track_in_current_playlist(index, 1); + if ui + .add_enabled(can_move_down, egui::Button::new("Move down")) + .clicked() + { self.move_track_in_current_playlist(index, 1); ui.close_menu(); } @@ -1226,6 +1295,11 @@ impl AudioOrbitApp { ui.close_menu(); } + if temporary_playback { + ui.small("Temporary playback is read-only."); + return; + } + let mut add_targets = self .state .playlists @@ -1274,6 +1348,20 @@ impl AudioOrbitApp { Some("Track is not present in another playlist."), ); + if ui + .add_enabled( + available_dj_track_count >= 2 && !self.dj_mix_is_running(), + egui::Button::new(ui_icons::label(Icon::Music, "Create DJ mix...")), + ) + .on_disabled_hover_text( + "Select at least two available tracks, or wait for current DJ mix export.", + ) + .clicked() + { + self.open_dj_mix_builder_for_selection(index); + ui.close_menu(); + } + let copy_label = if selected_count > 1 { "Copy selected to folder..." } else { @@ -1375,7 +1463,13 @@ fn paint_track_row_fast_scroll( } let title_font = egui::FontId::proportional(14.0); let metadata_font = egui::FontId::proportional(12.0); - let mut metadata_color = ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.50); + let mut metadata_color = ui + .visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.50); if missing { metadata_color = metadata_color.linear_multiply(0.42); } @@ -1437,7 +1531,11 @@ fn paint_track_row_fast_scroll( ui.painter().rect_filled( body_rect, 5.0, - if missing { fill.linear_multiply(0.42) } else { fill }, + if missing { + fill.linear_multiply(0.42) + } else { + fill + }, ); } @@ -1449,13 +1547,19 @@ fn paint_track_row_fast_scroll( }; let metadata_gap = if metadata.is_empty() { 0.0 } else { 6.0 }; let title_left = body_rect.left() + body_padding; - let title_right = (body_rect.right() - body_padding - metadata_width - metadata_gap) - .max(title_left + 24.0); + let title_right = + (body_rect.right() - body_padding - metadata_width - metadata_gap).max(title_left + 24.0); let title_rect = egui::Rect::from_min_max( egui::pos2(title_left, body_rect.top()), egui::pos2(title_right, body_rect.bottom()), ); - let clipped_title = ellipsize_to_width_exact(ui, title, title_rect.width(), title_font.clone(), text_color); + let clipped_title = ellipsize_to_width_exact( + ui, + title, + title_rect.width(), + title_font.clone(), + text_color, + ); ui.painter().with_clip_rect(title_rect).text( egui::pos2(title_rect.left(), title_rect.center().y), egui::Align2::LEFT_CENTER, @@ -1466,7 +1570,10 @@ fn paint_track_row_fast_scroll( if !metadata.is_empty() { let metadata_rect = egui::Rect::from_min_max( - egui::pos2(body_rect.right() - body_padding - metadata_width, body_rect.top()), + egui::pos2( + body_rect.right() - body_padding - metadata_width, + body_rect.top(), + ), egui::pos2(body_rect.right() - body_padding, body_rect.bottom()), ); ui.painter().with_clip_rect(metadata_rect).text( @@ -1494,7 +1601,10 @@ mod playlist_search_tests { Track::from_path(requested.clone(), None, 0), ]; - assert_eq!(find_track_by_exact_path_or_file_name(&playlist, &requested), Some(1)); + assert_eq!( + find_track_by_exact_path_or_file_name(&playlist, &requested), + Some(1) + ); } #[test] @@ -1506,7 +1616,10 @@ mod playlist_search_tests { Track::from_path(PathBuf::from("D:/Archive/TRACK.MP3"), None, 0), ]; - assert_eq!(find_track_by_exact_path_or_file_name(&playlist, &requested), Some(1)); + assert_eq!( + find_track_by_exact_path_or_file_name(&playlist, &requested), + Some(1) + ); } #[test] @@ -1519,6 +1632,9 @@ mod playlist_search_tests { 0, )]; - assert_eq!(find_track_by_exact_path_or_file_name(&playlist, &requested), None); + assert_eq!( + find_track_by_exact_path_or_file_name(&playlist, &requested), + None + ); } } diff --git a/src/app/updates.rs b/src/app/updates.rs index 0e4590e..b999582 100644 --- a/src/app/updates.rs +++ b/src/app/updates.rs @@ -34,7 +34,8 @@ impl AudioOrbitApp { let include_prereleases = self.state.update_settings.include_prereleases; let (sender, receiver) = mpsc::channel(); thread::spawn(move || { - let result = updater::check_for_update(include_prereleases).map_err(|error| error.to_string()); + let result = + updater::check_for_update(include_prereleases).map_err(|error| error.to_string()); let _ = sender.send(result); }); @@ -50,14 +51,16 @@ impl AudioOrbitApp { self.error_message = None; } pub(crate) fn process_update_events(&mut self) { - let update_check_result = self - .update_check_receiver - .as_ref() - .and_then(|receiver| match receiver.try_recv() { - Ok(result) => Some(result), - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => Some(Err("Update check stopped before returning a result.".to_owned())), - }); + let update_check_result = + self.update_check_receiver + .as_ref() + .and_then(|receiver| match receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err( + "Update check stopped before returning a result.".to_owned(), + )), + }); if let Some(result) = update_check_result { self.update_check_receiver = None; @@ -65,11 +68,25 @@ impl AudioOrbitApp { match result { Ok(check) => { if check.is_update_available { - let release_type = if check.prerelease { "Prerelease" } else { "Stable" }; - self.status_message = format!("{release_type} update available: v{}.", check.latest_version); + let release_type = if check.prerelease { + "Prerelease" + } else { + "Stable" + }; + self.status_message = format!( + "{release_type} update available: v{}.", + check.latest_version + ); } else { - let release_type = if check.prerelease { "prerelease" } else { "stable" }; - self.status_message = format!("No newer {release_type} release is available. Current version: v{}.", check.current_version); + let release_type = if check.prerelease { + "prerelease" + } else { + "stable" + }; + self.status_message = format!( + "No newer {release_type} release is available. Current version: v{}.", + check.current_version + ); } self.error_message = None; self.last_update_check = Some(check); @@ -81,14 +98,16 @@ impl AudioOrbitApp { } } - let install_result = self - .update_install_receiver - .as_ref() - .and_then(|receiver| match receiver.try_recv() { - Ok(result) => Some(result), - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => Some(Err("Update installer stopped before replacing the executable.".to_owned())), - }); + let install_result = + self.update_install_receiver + .as_ref() + .and_then(|receiver| match receiver.try_recv() { + Ok(result) => Some(result), + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => Some(Err( + "Update installer stopped before replacing the executable.".to_owned(), + )), + }); if let Some(result) = install_result { self.update_install_receiver = None; @@ -115,7 +134,9 @@ impl AudioOrbitApp { return; } if check.asset_download_url.is_none() { - self.error_message = Some("The selected release does not contain a Windows executable asset.".to_owned()); + self.error_message = Some( + "The selected release does not contain a Windows executable asset.".to_owned(), + ); return; } diff --git a/src/audio_player.rs b/src/audio_player.rs index c4f1418..41859bc 100644 --- a/src/audio_player.rs +++ b/src/audio_player.rs @@ -20,7 +20,8 @@ const RADIO_VISUALIZER_HISTORY_SECONDS: usize = 180; // which avoids synthetic/repeating patterns and keeps the strip tied to the // decoded audio itself. const RADIO_VISUALIZER_BUCKETS_PER_SECOND: usize = 64; -const RADIO_VISUALIZER_MAX_BUCKETS: usize = RADIO_VISUALIZER_HISTORY_SECONDS * RADIO_VISUALIZER_BUCKETS_PER_SECOND; +const RADIO_VISUALIZER_MAX_BUCKETS: usize = + RADIO_VISUALIZER_HISTORY_SECONDS * RADIO_VISUALIZER_BUCKETS_PER_SECOND; // The orbit position changes very slowly compared to the audio sample rate. // Updating gain coefficients once per small block avoids expensive sin/cos work // for every single decoded frame while keeping the movement perceptually smooth. @@ -61,7 +62,10 @@ fn live_orbit_gains(settings: DspSettings, frame_index: u64, sample_rate: u32) - let mut left = angle.cos(); let mut right = angle.sin(); - if matches!(settings.mode, crate::dsp::OrbitMode::VirtualEightDirectionOrbit) { + if matches!( + settings.mode, + crate::dsp::OrbitMode::VirtualEightDirectionOrbit + ) { let depth = phase.cos(); let rear = (-depth).max(0.0) * (settings.depth_cue_percent.min(100) as f32 / 100.0); let shade = 1.0 - rear * 0.22; @@ -151,7 +155,8 @@ impl Read for RadioStream { if let Ok(mut recording) = self.recorder.lock() { if let Some(recording) = recording.as_mut() { if recording.file.write_all(&buffer[..read]).is_ok() { - recording.bytes_written = recording.bytes_written.saturating_add(read as u64); + recording.bytes_written = + recording.bytes_written.saturating_add(read as u64); } } } @@ -332,7 +337,8 @@ fn fill_radio_waveform_gaps(values: &mut [f32]) { let current_value = values[index]; for offset in 1..=gap { let mix = offset as f32 / (gap + 1) as f32; - values[previous_index + offset] = previous_value * (1.0 - mix) + current_value * mix; + values[previous_index + offset] = + previous_value * (1.0 - mix) + current_value * mix; } } } @@ -342,7 +348,11 @@ fn fill_radio_waveform_gaps(values: &mut [f32]) { } fn silence_adjusted_position(seconds: f32, silence_ranges: Option<&[(f32, f32)]>) -> f32 { - let mut position = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 }; + let mut position = if seconds.is_finite() { + seconds.max(0.0) + } else { + 0.0 + }; let Some(ranges) = silence_ranges else { return position; }; @@ -362,7 +372,10 @@ fn silence_adjusted_position(seconds: f32, silence_ranges: Option<&[(f32, f32)]> position } -fn silence_ranges_to_frame_ranges(ranges: Option<&[(f32, f32)]>, sample_rate: u32) -> Vec<(u64, u64)> { +fn silence_ranges_to_frame_ranges( + ranges: Option<&[(f32, f32)]>, + sample_rate: u32, +) -> Vec<(u64, u64)> { let Some(ranges) = ranges else { return Vec::new(); }; @@ -397,7 +410,12 @@ struct LiveFileSource { } impl> LiveFileSource { - fn new(inner: S, settings: DspSettings, start_seconds: f32, silence_ranges: Option>) -> Self { + fn new( + inner: S, + settings: DspSettings, + start_seconds: f32, + silence_ranges: Option>, + ) -> Self { let input_channels = inner.channels().max(1); let sample_rate = inner.sample_rate().max(1); let start_seconds = silence_adjusted_position(start_seconds, silence_ranges.as_deref()); @@ -572,11 +590,7 @@ struct LiveRadioSource { } impl> LiveRadioSource { - fn new( - inner: S, - settings: DspSettings, - visualizer: RadioVisualizerHandle, - ) -> Self { + fn new(inner: S, settings: DspSettings, visualizer: RadioVisualizerHandle) -> Self { let input_channels = inner.channels().max(1); let sample_rate = inner.sample_rate().max(1); Self { @@ -590,7 +604,10 @@ impl> LiveRadioSource { cached_gains: LiveOrbitGains::default(), cached_gains_until_frame: 0, visualizer, - visualizer_analyzer: LiveRadioWaveformAnalyzer::new(sample_rate, RADIO_VISUALIZER_BUCKETS_PER_SECOND), + visualizer_analyzer: LiveRadioWaveformAnalyzer::new( + sample_rate, + RADIO_VISUALIZER_BUCKETS_PER_SECOND, + ), } } @@ -810,9 +827,10 @@ impl AudioPlayer { self.radio_visualizer = Arc::new(Mutex::new(RadioVisualizerState::default())); } let visualizer = Arc::clone(&self.radio_visualizer); - let radio_source = LiveRadioSource::new(decoder.convert_samples::(), settings, visualizer); - let sink = Sink::try_new(&self.stream_handle) - .context("failed to create audio playback sink")?; + let radio_source = + LiveRadioSource::new(decoder.convert_samples::(), settings, visualizer); + let sink = + Sink::try_new(&self.stream_handle).context("failed to create audio playback sink")?; sink.set_volume(self.volume_gain()); if fade_seconds > 0.05 { sink.append(FadeInSource::new(radio_source, fade_seconds)); @@ -867,8 +885,12 @@ impl AudioPlayer { } } - fs::create_dir_all(output_folder) - .with_context(|| format!("failed to create recording folder: {}", output_folder.display()))?; + fs::create_dir_all(output_folder).with_context(|| { + format!( + "failed to create recording folder: {}", + output_folder.display() + ) + })?; let path = unique_recording_path(output_folder, "audio-orbit-records-recording", "part"); let file = File::create(&path) .with_context(|| format!("failed to create recording file: {}", path.display()))?; @@ -918,7 +940,11 @@ impl AudioPlayer { })) } - pub fn radio_visualizer_frame(&self, requested_points: usize, visible_seconds: f32) -> RadioVisualizerFrame { + pub fn radio_visualizer_frame( + &self, + requested_points: usize, + visible_seconds: f32, + ) -> RadioVisualizerFrame { let Ok(mut state) = self.radio_visualizer.lock() else { return RadioVisualizerFrame::default(); }; @@ -928,8 +954,7 @@ impl AudioPlayer { let now = Instant::now(); let requested_points = requested_points.clamp(1, RADIO_VISUALIZER_MAX_BUCKETS); - let visible_seconds = visible_seconds - .clamp(1.0, RADIO_VISUALIZER_HISTORY_SECONDS as f32); + let visible_seconds = visible_seconds.clamp(1.0, RADIO_VISUALIZER_HISTORY_SECONDS as f32); let bucket_seconds = (visible_seconds / requested_points as f32).max(1.0 / 240.0); let max_age = visible_seconds + bucket_seconds * 2.0; @@ -1001,15 +1026,25 @@ impl AudioPlayer { nonzero.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)); let last = nonzero.len().saturating_sub(1); let target_floor = nonzero[((last as f32 * 0.06) as usize).min(last)].min(0.22); - let target_peak = nonzero[((last as f32 * 0.94) as usize).min(last)].max(target_floor + 0.18); + let target_peak = + nonzero[((last as f32 * 0.94) as usize).min(last)].max(target_floor + 0.18); // Slow range tracking gives radio a full-track-like overview feel without // making every UI frame rescale the entire strip. - let floor_blend = if target_floor > state.display_floor { 0.010 } else { 0.040 }; - state.display_floor = (state.display_floor * (1.0 - floor_blend) + target_floor * floor_blend) + let floor_blend = if target_floor > state.display_floor { + 0.010 + } else { + 0.040 + }; + state.display_floor = (state.display_floor * (1.0 - floor_blend) + + target_floor * floor_blend) .clamp(0.0, 0.24); - let peak_blend = if target_peak > state.display_peak { 0.040 } else { 0.010 }; + let peak_blend = if target_peak > state.display_peak { + 0.040 + } else { + 0.010 + }; state.display_peak = (state.display_peak * (1.0 - peak_blend) + target_peak * peak_blend) .max(state.display_floor + 0.16) .clamp(0.22, 1.0); @@ -1031,7 +1066,6 @@ impl AudioPlayer { RadioVisualizerFrame { bars } } - pub fn play_file_streaming_with_cached_waveform_and_crossfade( &mut self, path: &Path, @@ -1051,7 +1085,8 @@ impl AudioPlayer { } else { 0.0 }; - let start_seconds = silence_adjusted_position(start_seconds, cached_silence_ranges.as_deref()); + let start_seconds = + silence_adjusted_position(start_seconds, cached_silence_ranges.as_deref()); let file = File::open(path) .with_context(|| format!("failed to open audio file: {}", path.display()))?; let mut decoder = Decoder::new(BufReader::new(file)) @@ -1099,9 +1134,7 @@ impl AudioPlayer { path, settings, start_seconds, - decoder - .convert_samples::() - .skip_duration(seek_to), + decoder.convert_samples::().skip_duration(seek_to), total_duration, input_channels, sample_rate, @@ -1132,7 +1165,12 @@ impl AudioPlayer { let remaining_duration = total_duration .map(|duration| duration.saturating_sub(Duration::from_secs_f32(start_seconds))) .unwrap_or(Duration::ZERO); - let source = LiveFileSource::new(source, settings, start_seconds, cached_silence_ranges.clone()); + let source = LiveFileSource::new( + source, + settings, + start_seconds, + cached_silence_ranges.clone(), + ); let fade_seconds = crossfade_seconds.max(0.0); if fade_seconds > 0.05 { @@ -1144,8 +1182,8 @@ impl AudioPlayer { self.stop(); } - let sink = Sink::try_new(&self.stream_handle) - .context("failed to create audio playback sink")?; + let sink = + Sink::try_new(&self.stream_handle).context("failed to create audio playback sink")?; sink.set_volume(self.volume_gain()); if fade_seconds > 0.05 { sink.append(FadeInSource::new(source, fade_seconds)); @@ -1164,7 +1202,9 @@ impl AudioPlayer { self.current_settings = Some(settings); self.current_radio_url = None; - let original_duration_seconds = total_duration.map(|duration| duration.as_secs_f32()).unwrap_or(0.0); + let original_duration_seconds = total_duration + .map(|duration| duration.as_secs_f32()) + .unwrap_or(0.0); Ok(PlaybackInfo { path: path.to_path_buf(), original_duration_seconds, @@ -1212,8 +1252,16 @@ impl AudioPlayer { } = prepared; self.stop(); - let rendered_duration = Duration::from_secs_f32(render_info.rendered_duration_seconds.max(0.0)); - self.play_processed_samples(processed_samples, sample_rate, rendered_duration, &path, settings, start_seconds)?; + let rendered_duration = + Duration::from_secs_f32(render_info.rendered_duration_seconds.max(0.0)); + self.play_processed_samples( + processed_samples, + sample_rate, + rendered_duration, + &path, + settings, + start_seconds, + )?; Ok(playback_info(&path, render_info)) } @@ -1226,16 +1274,21 @@ impl AudioPlayer { let compensated_start_seconds = prepared.start_seconds + render_elapsed_seconds.max(0.0); if render_elapsed_seconds > 0.025 { - let trim_frames = (render_elapsed_seconds * prepared.sample_rate as f32).round().max(0.0) as usize; + let trim_frames = (render_elapsed_seconds * prepared.sample_rate as f32) + .round() + .max(0.0) as usize; let trim_samples = (trim_frames * 2).min(prepared.processed_samples.len()); if trim_samples > 0 && trim_samples < prepared.processed_samples.len() { prepared.processed_samples.drain(0..trim_samples); - prepared.render_info.rendered_duration_seconds = (prepared.render_info.rendered_duration_seconds - render_elapsed_seconds).max(0.0); + prepared.render_info.rendered_duration_seconds = + (prepared.render_info.rendered_duration_seconds - render_elapsed_seconds) + .max(0.0); } } self.stop(); - let rendered_duration = Duration::from_secs_f32(prepared.render_info.rendered_duration_seconds.max(0.0)); + let rendered_duration = + Duration::from_secs_f32(prepared.render_info.rendered_duration_seconds.max(0.0)); self.play_processed_samples( prepared.processed_samples, prepared.sample_rate, @@ -1257,7 +1310,11 @@ impl AudioPlayer { .max(0.0) .min(prepared.render_info.rendered_duration_seconds.max(0.0)); - apply_fade_in(&mut prepared.processed_samples, prepared.sample_rate, fade_seconds); + apply_fade_in( + &mut prepared.processed_samples, + prepared.sample_rate, + fade_seconds, + ); if fade_seconds > 0.05 { let _ = self.stop_radio_recording(); @@ -1268,7 +1325,8 @@ impl AudioPlayer { self.stop(); } - let rendered_duration = Duration::from_secs_f32(prepared.render_info.rendered_duration_seconds.max(0.0)); + let rendered_duration = + Duration::from_secs_f32(prepared.render_info.rendered_duration_seconds.max(0.0)); self.play_processed_samples( prepared.processed_samples, prepared.sample_rate, @@ -1344,7 +1402,9 @@ impl AudioPlayer { let position = self.current_start_offset_seconds + elapsed.as_secs_f32(); match self.current_duration { - Some(duration) => position.min(self.current_start_offset_seconds + duration.as_secs_f32()), + Some(duration) => { + position.min(self.current_start_offset_seconds + duration.as_secs_f32()) + } None => position, } } @@ -1371,8 +1431,8 @@ impl AudioPlayer { settings: DspSettings, start_seconds: f32, ) -> Result<()> { - let sink = Sink::try_new(&self.stream_handle) - .context("failed to create audio playback sink")?; + let sink = + Sink::try_new(&self.stream_handle).context("failed to create audio playback sink")?; let source = SamplesBuffer::new(2, sample_rate, samples); sink.set_volume(self.volume_gain()); diff --git a/src/bin/audio-orbit-dev.rs b/src/bin/audio-orbit-dev.rs index 6806de8..6fbb665 100644 --- a/src/bin/audio-orbit-dev.rs +++ b/src/bin/audio-orbit-dev.rs @@ -88,7 +88,11 @@ fn start_app(root: &Path) -> Option { return None; } + let app_data_dir = root.join(".cache").join("app-data"); + println!("app data: {}", app_data_dir.display()); + match Command::new(&exe) + .env("AUDIO_ORBIT_APP_DATA_DIR", &app_data_dir) .stdin(Stdio::null()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -129,7 +133,7 @@ fn stop_child(child: &mut Option) { fn app_executable_path(root: &Path) -> PathBuf { let target_dir = env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) - .unwrap_or_else(|| root.join("target")); + .unwrap_or_else(|| root.join(".cache").join("cargo-target")); let exe_name = if cfg!(windows) { "audio-orbit.exe" @@ -167,9 +171,9 @@ fn collect_fingerprint(path: &Path, fingerprint: &mut FileFingerprint) { if metadata.is_file() { fingerprint.files = fingerprint.files.saturating_add(1); fingerprint.bytes = fingerprint.bytes.saturating_add(metadata.len()); - fingerprint.modified_nanos = fingerprint - .modified_nanos - .max(system_time_to_nanos(metadata.modified().unwrap_or(UNIX_EPOCH))); + fingerprint.modified_nanos = fingerprint.modified_nanos.max(system_time_to_nanos( + metadata.modified().unwrap_or(UNIX_EPOCH), + )); return; } @@ -198,6 +202,7 @@ fn should_ignore(path: &Path) -> bool { matches!( value.to_str(), Some("target") + | Some(".cache") | Some(".git") | Some(".audio-orbit-data") | Some(".audio-orbit-dll") diff --git a/src/config.rs b/src/config.rs index ee7f4cc..a3d2a20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ use std::{ use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter}; pub const FAVORITES_PLAYLIST_NAME: &str = "Favorites"; +pub const TEMPORARY_PLAYLIST_NAME: &str = "Temporary playback"; const BACKUP_STATE_ENTRY: &str = "audio-orbit/state.json"; const BACKUP_META_ENTRY: &str = "audio-orbit/backup.json"; @@ -85,6 +86,7 @@ pub enum PlaylistKind { Favorites, Manual, Folder, + Temporary, } impl Default for PlaylistKind { @@ -99,6 +101,7 @@ impl PlaylistKind { Self::Favorites => Icon::Heart, Self::Manual => Icon::ListMusic, Self::Folder => Icon::Folder, + Self::Temporary => Icon::ListMusic, }; char::from(icon).to_string() @@ -109,15 +112,16 @@ impl PlaylistKind { Self::Favorites => "Favorites", Self::Manual => "Manual playlist", Self::Folder => "Folder playlist", + Self::Temporary => "Temporary playback", } } pub fn accepts_manual_tracks(&self) -> bool { - !matches!(self, Self::Folder) + matches!(self, Self::Favorites | Self::Manual) } pub fn can_delete(&self) -> bool { - !matches!(self, Self::Favorites) + !matches!(self, Self::Favorites | Self::Temporary) } } @@ -159,7 +163,9 @@ impl Track { let group = folder_group_for_path(&path, root, folder_depth); // Keep large folder imports responsive: expensive decoder/tag metadata is filled // lazily from playback results instead of being read for every scanned file. - let file_metadata = fs::metadata(&path).ok().filter(|metadata| metadata.is_file()); + let file_metadata = fs::metadata(&path) + .ok() + .filter(|metadata| metadata.is_file()); let metadata = TrackMetadata { size_bytes: file_metadata.as_ref().map(|metadata| metadata.len()), ..Default::default() @@ -265,7 +271,6 @@ fn default_playback_session_source() -> String { "music".to_owned() } - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct TrackAvailabilityStats { pub restored: usize, @@ -320,6 +325,38 @@ impl Playlist { } } + pub fn temporary() -> Self { + Self { + name: TEMPORARY_PLAYLIST_NAME.to_owned(), + tracks: Vec::new(), + source_folder: None, + folder_depth: 0, + selected_group: None, + repeat_selection: Vec::new(), + kind: PlaylistKind::Temporary, + } + } + + pub fn add_temporary_files(&mut self, files: Vec) -> Vec { + if self.kind != PlaylistKind::Temporary { + return Vec::new(); + } + + let mut added = Vec::new(); + for path in files { + if self + .tracks + .iter() + .any(|track| same_path(&track.path, &path)) + { + continue; + } + self.tracks.push(Track::from_path(path.clone(), None, 0)); + added.push(path); + } + added + } + pub fn from_folder( name: impl Into, source_folder: PathBuf, @@ -359,8 +396,17 @@ impl Playlist { added_paths } - pub fn add_track_path(&mut self, path: PathBuf, root: Option<&Path>, folder_depth: usize) -> bool { - if self.tracks.iter().any(|track| same_path(&track.path, &path)) { + pub fn add_track_path( + &mut self, + path: PathBuf, + root: Option<&Path>, + folder_depth: usize, + ) -> bool { + if self + .tracks + .iter() + .any(|track| same_path(&track.path, &path)) + { return false; } @@ -662,7 +708,11 @@ impl Playlist { return; }; - if !self.tracks.iter().any(|track| track.group == selected_group) { + if !self + .tracks + .iter() + .any(|track| track.group == selected_group) + { self.selected_group = None; } } @@ -707,7 +757,8 @@ impl Playlist { } self.ensure_favorite_added_sequences(); - self.tracks.sort_by_key(|track| Reverse(track.favorite_added_sequence.unwrap_or(0))); + self.tracks + .sort_by_key(|track| Reverse(track.favorite_added_sequence.unwrap_or(0))); } pub fn sort_tracks(&mut self) { @@ -735,8 +786,6 @@ impl DspProfile { } } - - #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RecordingSettings { #[serde(default)] @@ -753,12 +802,12 @@ impl RecordingSettings { } pub fn default_recording_output_folder() -> Option { - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(|parent| parent.join(".audio-orbit-records"))) + std::env::current_exe().ok().and_then(|path| { + path.parent() + .map(|parent| parent.join(".audio-orbit-records")) + }) } - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LibrarySettings { #[serde(default, alias = "auto_sync_folder_playlists")] @@ -790,7 +839,6 @@ impl Default for UpdateSettings { } } - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RepeatMode { Off, @@ -869,8 +917,6 @@ fn default_volume_percent() -> u8 { 100 } - - #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] pub struct WindowGeometry { pub x: f32, @@ -995,7 +1041,33 @@ impl Default for SavedState { } } +const APP_DATA_DIR_ENV: &str = "AUDIO_ORBIT_APP_DATA_DIR"; + pub fn app_data_dir() -> Option { + resolve_app_data_dir( + std::env::var_os(APP_DATA_DIR_ENV).map(PathBuf::from), + std::env::current_dir().ok(), + std::env::current_exe().ok(), + ) +} + +fn resolve_app_data_dir( + override_dir: Option, + current_dir: Option, + current_exe: Option, +) -> Option { + if let Some(path) = override_dir.filter(|path| !path.as_os_str().is_empty()) { + return Some(if path.is_absolute() { + path + } else { + current_dir?.join(path) + }); + } + + current_exe.and_then(|path| path.parent().map(|parent| parent.join(".audio-orbit-data"))) +} + +fn portable_app_data_dir() -> Option { std::env::current_exe() .ok() .and_then(|path| path.parent().map(|parent| parent.join(".audio-orbit-data"))) @@ -1005,9 +1077,75 @@ pub fn state_path() -> Option { app_data_dir().map(|dir| dir.join("state.json")) } -fn legacy_state_path() -> Option { - ProjectDirs::from("dev", "AudioOrbit", "Audio Orbit") +fn legacy_state_paths(primary_path: &Path) -> Vec { + let mut paths = Vec::new(); + + if cfg!(debug_assertions) { + if let Ok(root) = std::env::current_dir() { + push_unique_state_path( + &mut paths, + primary_path, + root.join("target") + .join("debug") + .join(".audio-orbit-data") + .join("state.json"), + ); + push_unique_state_path( + &mut paths, + primary_path, + root.join(".cache") + .join("cargo-target") + .join("debug") + .join(".audio-orbit-data") + .join("state.json"), + ); + push_unique_state_path( + &mut paths, + primary_path, + root.join(".audio-orbit-data").join("state.json"), + ); + } + } + + if let Some(path) = portable_app_data_dir().map(|directory| directory.join("state.json")) { + push_unique_state_path(&mut paths, primary_path, path); + } + + if let Some(path) = ProjectDirs::from("dev", "AudioOrbit", "Audio Orbit") .map(|dirs| dirs.data_local_dir().join("state.json")) + { + push_unique_state_path(&mut paths, primary_path, path); + } + + paths +} + +fn push_unique_state_path(paths: &mut Vec, primary_path: &Path, candidate: PathBuf) { + if candidate != primary_path && !paths.contains(&candidate) { + paths.push(candidate); + } +} + +fn read_state_from_path(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) +} + +fn migrate_companion_data(legacy_state_path: &Path, new_state_path: &Path) { + let Some(legacy_directory) = legacy_state_path.parent() else { + return; + }; + let Some(new_directory) = new_state_path.parent() else { + return; + }; + + let legacy_cache = legacy_directory.join("dj-analysis-cache.json"); + let new_cache = new_directory.join("dj-analysis-cache.json"); + if legacy_cache.is_file() && !new_cache.exists() { + let _ = fs::create_dir_all(new_directory); + let _ = fs::copy(legacy_cache, new_cache); + } } pub fn load_state() -> SavedState { @@ -1015,14 +1153,14 @@ pub fn load_state() -> SavedState { return SavedState::default(); }; - if let Ok(contents) = fs::read_to_string(&path) { - return serde_json::from_str(&contents).unwrap_or_default(); + if let Some(state) = read_state_from_path(&path) { + return state; } - if let Some(legacy_path) = legacy_state_path() { - if let Ok(contents) = fs::read_to_string(&legacy_path) { - let state = serde_json::from_str(&contents).unwrap_or_default(); + for legacy_path in legacy_state_paths(&path) { + if let Some(state) = read_state_from_path(&legacy_path) { let _ = write_state_to_path(&state, &path); + migrate_companion_data(&legacy_path, &path); return state; } } @@ -1030,9 +1168,62 @@ pub fn load_state() -> SavedState { SavedState::default() } +fn persisted_state(state: &SavedState) -> SavedState { + let mut persisted = state.clone(); + let temporary_index = persisted + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Temporary); + + if let Some(index) = temporary_index { + persisted.playlists.remove(index); + + if persisted.selected_playlist_index == index { + persisted.selected_playlist_index = persisted + .playlists + .iter() + .position(|playlist| playlist.kind == PlaylistKind::Manual) + .unwrap_or(0); + } else if persisted.selected_playlist_index > index { + persisted.selected_playlist_index -= 1; + } + + if persisted + .last_played_track + .as_ref() + .map(|track| track.playlist_index == index) + .unwrap_or(false) + { + persisted.last_played_track = None; + } else if let Some(track) = persisted.last_played_track.as_mut() { + if track.playlist_index > index { + track.playlist_index -= 1; + } + } + + if persisted.playback_session.playlist_index == Some(index) { + persisted.playback_session = PlaybackSession::default(); + } else if let Some(playlist_index) = persisted.playback_session.playlist_index.as_mut() { + if *playlist_index > index { + *playlist_index -= 1; + } + } + } + + if persisted.playlists.is_empty() { + persisted.playlists.push(Playlist::favorites()); + persisted.playlists.push(Playlist::new("Local music")); + } + if persisted.selected_playlist_index >= persisted.playlists.len() { + persisted.selected_playlist_index = 0; + } + + persisted +} + pub fn save_state(state: &SavedState) -> Result<()> { let path = state_path().context("could not resolve the application data path")?; - write_state_to_path(state, &path) + write_state_to_path(&persisted_state(state), &path) } pub fn export_state_zip(state: &SavedState, path: &Path) -> Result<()> { @@ -1055,7 +1246,7 @@ pub fn export_state_zip(state: &SavedState, path: &Path) -> Result<()> { zip.write_all(serde_json::to_string_pretty(&meta)?.as_bytes())?; zip.start_file(BACKUP_STATE_ENTRY, options)?; - zip.write_all(serde_json::to_string_pretty(state)?.as_bytes())?; + zip.write_all(serde_json::to_string_pretty(&persisted_state(state))?.as_bytes())?; zip.finish()?; Ok(()) } @@ -1096,12 +1287,15 @@ pub fn scan_audio_folder(root: &Path) -> Result { for entry in entries { let entry = entry.with_context(|| { - format!("failed to inspect a folder entry under {}", directory.display()) + format!( + "failed to inspect a folder entry under {}", + directory.display() + ) })?; let path = entry.path(); - let file_type = entry.file_type().with_context(|| { - format!("failed to read file type: {}", path.display()) - })?; + let file_type = entry + .file_type() + .with_context(|| format!("failed to read file type: {}", path.display()))?; // Never follow symbolic links or Windows reparse points during // recursive scans. This prevents cycles and avoids leaving the @@ -1117,7 +1311,9 @@ pub fn scan_audio_folder(root: &Path) -> Result { } } - files.sort_by(|left, right| natural_key(&left.to_string_lossy()).cmp(&natural_key(&right.to_string_lossy()))); + files.sort_by(|left, right| { + natural_key(&left.to_string_lossy()).cmp(&natural_key(&right.to_string_lossy())) + }); Ok(FolderScanResult { files }) } @@ -1127,7 +1323,18 @@ pub fn is_supported_audio_file(path: &Path) -> bool { .map(|extension| { matches!( extension.to_lowercase().as_str(), - "mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "mp4" | "aac" | "aiff" | "aif" | "ape" | "wv" + "mp3" + | "wav" + | "flac" + | "ogg" + | "opus" + | "m4a" + | "mp4" + | "aac" + | "aiff" + | "aif" + | "ape" + | "wv" ) }) .unwrap_or(false) @@ -1189,12 +1396,16 @@ pub fn display_file_name(path: &Path) -> String { fn write_state_to_path(state: &SavedState, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create application data directory: {}", parent.display()))?; + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create application data directory: {}", + parent.display() + ) + })?; } - let contents = serde_json::to_string_pretty(state) - .context("failed to serialize application state")?; + let contents = + serde_json::to_string_pretty(state).context("failed to serialize application state")?; fs::write(path, contents) .with_context(|| format!("failed to save application state: {}", path.display()))?; @@ -1213,7 +1424,9 @@ fn read_track_metadata(path: &Path) -> Result { duration_seconds: Some(properties.duration().as_secs_f32()).filter(|value| *value > 0.0), sample_rate_hz: properties.sample_rate(), channels: properties.channels(), - bitrate_kbps: properties.audio_bitrate().or_else(|| properties.overall_bitrate()), + bitrate_kbps: properties + .audio_bitrate() + .or_else(|| properties.overall_bitrate()), }) } @@ -1270,6 +1483,36 @@ mod tests { .collect() } + #[test] + fn app_data_override_resolves_relative_to_working_directory() { + let resolved = resolve_app_data_dir( + Some(PathBuf::from(".cache/app-data")), + Some(PathBuf::from("C:/Projects/audio-orbit")), + Some(PathBuf::from( + "C:/Projects/audio-orbit/target/debug/audio-orbit.exe", + )), + ); + + assert_eq!( + resolved, + Some(PathBuf::from("C:/Projects/audio-orbit/.cache/app-data")) + ); + } + + #[test] + fn app_data_without_override_remains_portable() { + let resolved = resolve_app_data_dir( + None, + Some(PathBuf::from("C:/Projects/audio-orbit")), + Some(PathBuf::from("C:/Apps/Audio Orbit/audio-orbit.exe")), + ); + + assert_eq!( + resolved, + Some(PathBuf::from("C:/Apps/Audio Orbit/.audio-orbit-data")) + ); + } + #[test] fn playback_settings_keep_auto_output_switch_disabled_for_existing_state() { let settings: PlaybackSettings = serde_json::from_str("{}").unwrap(); @@ -1292,7 +1535,10 @@ mod tests { assert_eq!(track_paths(&favorites), owned_paths(&[&alpha, &beta])); assert!(favorites.add_track_path(charlie.clone(), None, 0)); - assert_eq!(track_paths(&favorites), owned_paths(&[&charlie, &alpha, &beta])); + assert_eq!( + track_paths(&favorites), + owned_paths(&[&charlie, &alpha, &beta]) + ); assert!(!favorites.add_track_path(alpha.clone(), None, 0)); favorites.sort_favorites_by_added(); @@ -1368,20 +1614,17 @@ mod tests { #[test] fn library_settings_migrate_previous_automatic_sync_key() { - let settings: LibrarySettings = serde_json::from_str( - r#"{"auto_sync_folder_playlists":true}"#, - ) - .unwrap(); + let settings: LibrarySettings = + serde_json::from_str(r#"{"auto_sync_folder_playlists":true}"#).unwrap(); assert!(settings.auto_sync_selected_playlist); } #[test] fn tracks_from_existing_state_default_to_available() { - let track: Track = serde_json::from_str( - r#"{"path":"C:/Music/Track.mp3","title":"Track","group":"Root"}"#, - ) - .unwrap(); + let track: Track = + serde_json::from_str(r#"{"path":"C:/Music/Track.mp3","title":"Track","group":"Root"}"#) + .unwrap(); assert!(!track.missing); } @@ -1428,19 +1671,26 @@ mod tests { assert_eq!(stats.newly_missing, 1); assert_eq!(stats.present_total, 3); assert_eq!(stats.missing_total, 1); + assert!( + playlist + .tracks + .iter() + .find(|track| same_path(&track.path, &alpha)) + .unwrap() + .missing + ); + assert!( + !playlist + .tracks + .iter() + .find(|track| same_path(&track.path, &gamma)) + .unwrap() + .missing + ); assert!(playlist .tracks .iter() - .find(|track| same_path(&track.path, &alpha)) - .unwrap() - .missing); - assert!(!playlist - .tracks - .iter() - .find(|track| same_path(&track.path, &gamma)) - .unwrap() - .missing); - assert!(playlist.tracks.iter().any(|track| same_path(&track.path, &delta))); + .any(|track| same_path(&track.path, &delta))); } #[test] @@ -1449,19 +1699,18 @@ mod tests { let alpha = root.join("Alpha.mp3"); let beta = root.join("Beta.mp3"); let gamma = root.join("Gamma.mp3"); - let mut playlist = Playlist::from_folder( - "Folder", - root, - 1, - vec![alpha.clone(), beta.clone()], - ); + let mut playlist = + Playlist::from_folder("Folder", root, 1, vec![alpha.clone(), beta.clone()]); let stats = playlist.merge_tracks_from_folder_scan(&[beta.clone(), gamma.clone()]); assert_eq!(stats.added, 1); assert_eq!(stats.newly_missing, 1); assert_eq!(stats.missing_total, 1); - assert_eq!(track_paths(&playlist), owned_paths(&[&alpha, &beta, &gamma])); + assert_eq!( + track_paths(&playlist), + owned_paths(&[&alpha, &beta, &gamma]) + ); assert!(playlist.tracks[0].missing); assert!(!playlist.tracks[1].missing); assert!(!playlist.tracks[2].missing); @@ -1510,7 +1759,11 @@ mod tests { "Folder", root, 1, - vec![artist_a_one.clone(), artist_b_one.clone(), artist_a_two.clone()], + vec![ + artist_a_one.clone(), + artist_b_one.clone(), + artist_a_two.clone(), + ], ); playlist.tracks = vec![ Track::from_path(artist_a_one.clone(), playlist.source_folder.as_deref(), 1), @@ -1531,12 +1784,8 @@ mod tests { let root = PathBuf::from("C:/Music"); let alpha = root.join("Alpha.mp3"); let beta = root.join("Beta.mp3"); - let mut playlist = Playlist::from_folder( - "Folder", - root, - 1, - vec![alpha.clone(), beta.clone()], - ); + let mut playlist = + Playlist::from_folder("Folder", root, 1, vec![alpha.clone(), beta.clone()]); playlist.tracks.swap(0, 1); playlist.merge_tracks_from_folder_scan(std::slice::from_ref(&beta)); @@ -1589,10 +1838,7 @@ mod tests { track.missing = false; } - let availability = BTreeMap::from([ - (path_key(&alpha), false), - (path_key(&beta), true), - ]); + let availability = BTreeMap::from([(path_key(&alpha), false), (path_key(&beta), true)]); let missing = playlist.apply_track_availability(&availability); assert_eq!(missing.newly_missing, 1); @@ -1606,4 +1852,29 @@ mod tests { assert_eq!(restored.restored, 1); assert_eq!(restored.missing_total, 0); } + + #[test] + fn temporary_playlist_is_runtime_only_and_read_only() { + let mut state = SavedState::default(); + state.playlists.push(Playlist::temporary()); + state.selected_playlist_index = state.playlists.len() - 1; + state.last_played_track = Some(LastPlayedTrack { + playlist_index: state.selected_playlist_index, + track_path: PathBuf::from("C:/Music/mix.mp3"), + }); + state.playback_session.playlist_index = Some(state.selected_playlist_index); + state.playback_session.track_path = Some(PathBuf::from("C:/Music/mix.mp3")); + state.playback_session.was_active = true; + + let persisted = persisted_state(&state); + + assert!(persisted + .playlists + .iter() + .all(|playlist| playlist.kind != PlaylistKind::Temporary)); + assert!(persisted.last_played_track.is_none()); + assert!(persisted.playback_session.playlist_index.is_none()); + assert!(!PlaylistKind::Temporary.accepts_manual_tracks()); + assert!(!PlaylistKind::Temporary.can_delete()); + } } diff --git a/src/dj_mix.rs b/src/dj_mix.rs new file mode 100644 index 0000000..9d8a5fb --- /dev/null +++ b/src/dj_mix.rs @@ -0,0 +1,3758 @@ +use crate::{ + app_data_dir, time_stretch::pitch_preserving_stretch, DjBridgeMode, DjMixEvent, DjMixOptions, + DjMixStyle, DjMixTrack, DjTrackSectionMode, +}; +use anyhow::{anyhow, Context, Result}; +use ebur128::{EbuR128, Mode}; +use rodio::{source::UniformSourceIterator, Decoder, Source}; +use serde::{Deserialize, Serialize}; +use shine_rs::{Mp3Encoder, Mp3EncoderConfig, StereoMode}; +use std::{ + cmp::Ordering, + collections::BTreeMap, + env, + ffi::{OsStr, OsString}, + fs::{self, File}, + hash::{Hash, Hasher}, + io::{BufReader, BufWriter, Write}, + path::{Path, PathBuf}, + process::{Child, Command, ExitStatus, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering as AtomicOrdering}, + mpsc::Sender, + Arc, + }, + thread, + time::{Duration, Instant, UNIX_EPOCH}, +}; + +const ENGINE_SCHEMA_VERSION: u32 = 4; +const ANALYZER_VERSION: &str = "audio-orbit+optional-essentia-v3"; +const ENGINE_VERSION: &str = "human-dj-v8-continuous-phrase-bridge"; +const OUTPUT_SAMPLE_RATE: u32 = 44_100; +const OUTPUT_CHANNELS: u16 = 2; +const ANALYSIS_RATE_HZ: usize = 100; +const ENERGY_RATE_HZ: usize = 2; +const MAX_ANALYSIS_SECONDS: usize = 60 * 15; +const MIN_BPM: f32 = 70.0; +const MAX_BPM: f32 = 180.0; +const DEFAULT_BPM: f32 = 120.0; +const MAX_SMART_TEMPO_CHANGE: f32 = 0.06; +const TARGET_LUFS: f64 = -14.0; +const OUTPUT_PEAK_LIMIT: f32 = 0.96; +const MIN_TRANSITION_BED_GAIN: f32 = 0.08; +const MIN_DROP_CUT_GAIN: f32 = 0.12; +const MIN_SECTION_SECONDS: f32 = 24.0; +const MAX_AUTO_SECTION_SECONDS: f32 = 150.0; + +#[derive(Clone, Debug)] +struct ExternalCommand { + program: PathBuf, + prefix_args: Vec, + display_name: &'static str, +} + +impl ExternalCommand { + fn command(&self) -> Command { + let mut command = Command::new(&self.program); + command.args(&self.prefix_args); + command + } + + fn label(&self) -> String { + format!("{} ({})", self.display_name, self.program.display()) + } +} + +#[derive(Clone, Debug, Default)] +struct ProfessionalToolchain { + essentia: Option, + rubber_band: Option, + demucs: Option, +} + +impl ProfessionalToolchain { + fn detect(enabled: bool) -> Self { + if !enabled { + return Self::default(); + } + Self { + essentia: resolve_external_command( + "AUDIO_ORBIT_ESSENTIA_PATH", + &[ + "essentia_streaming_extractor_music.exe", + "essentia_streaming_extractor_music", + ], + &[], + "Essentia", + ), + rubber_band: resolve_external_command( + "AUDIO_ORBIT_RUBBERBAND_PATH", + &[ + "rubberband-r3.exe", + "rubberband.exe", + "rubberband-r3", + "rubberband", + ], + &[], + "Rubber Band R3", + ), + demucs: resolve_demucs_command(), + } + } + + fn summary(&self) -> String { + format!( + "Professional tools: Essentia {} · Rubber Band {} · Demucs {}", + availability_mark(self.essentia.is_some()), + availability_mark(self.rubber_band.is_some()), + availability_mark(self.demucs.is_some()), + ) + } + + fn report(&self) -> ProfessionalToolReport { + ProfessionalToolReport { + essentia: self + .essentia + .as_ref() + .map(ExternalCommand::label) + .unwrap_or_else(|| "not available; built-in rhythm analyzer used".to_owned()), + rubber_band: self + .rubber_band + .as_ref() + .map(ExternalCommand::label) + .unwrap_or_else(|| "not available; built-in WSOLA used".to_owned()), + demucs: self + .demucs + .as_ref() + .map(ExternalCommand::label) + .unwrap_or_else(|| "not available; full-mix fallback used".to_owned()), + } + } +} + +pub(crate) fn professional_tool_status_summary() -> String { + ProfessionalToolchain::detect(true).summary() +} + +fn availability_mark(available: bool) -> &'static str { + if available { + "detected" + } else { + "missing" + } +} + +fn resolve_demucs_command() -> Option { + if let Some(command) = resolve_external_command( + "AUDIO_ORBIT_DEMUCS_PATH", + &["demucs.exe", "demucs"], + &[], + "Demucs", + ) { + return Some(command); + } + let python = env::var_os("AUDIO_ORBIT_DEMUCS_PYTHON") + .and_then(|value| resolve_executable(OsStr::new(&value)))?; + Some(ExternalCommand { + program: python, + prefix_args: vec![OsString::from("-m"), OsString::from("demucs")], + display_name: "Demucs", + }) +} + +fn resolve_external_command( + env_name: &str, + candidates: &[&str], + prefix_args: &[&str], + display_name: &'static str, +) -> Option { + let program = env::var_os(env_name) + .and_then(|value| resolve_executable(OsStr::new(&value))) + .or_else(|| { + candidates + .iter() + .find_map(|candidate| resolve_executable(OsStr::new(*candidate))) + })?; + Some(ExternalCommand { + program, + prefix_args: prefix_args + .iter() + .map(|value| OsString::from(*value)) + .collect(), + display_name, + }) +} + +fn resolve_executable(candidate: &OsStr) -> Option { + let candidate_path = PathBuf::from(candidate); + if candidate_path.components().count() > 1 || candidate_path.is_absolute() { + return candidate_path.is_file().then_some(candidate_path); + } + + let path = env::var_os("PATH")?; + let extensions = executable_extensions(&candidate_path); + for directory in env::split_paths(&path) { + for extension in &extensions { + let mut file_name = candidate_path.clone(); + if !extension.is_empty() && file_name.extension().is_none() { + file_name.set_extension(extension.trim_start_matches('.')); + } + let full = directory.join(file_name); + if full.is_file() { + return Some(full); + } + } + } + None +} + +fn executable_extensions(candidate: &Path) -> Vec { + if candidate.extension().is_some() { + return vec![String::new()]; + } + #[cfg(windows)] + { + let mut values = env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_owned()) + .split(';') + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .collect::>(); + values.push(String::new()); + values + } + #[cfg(not(windows))] + { + vec![String::new()] + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ExportRequest { + pub tracks: Vec, + pub options: DjMixOptions, + pub custom_bridge_path: Option, + pub output_path: PathBuf, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct TrackAnalysis { + bpm: f32, + bpm_confidence: f32, + beat_offset_seconds: f32, + downbeat_offset_seconds: f32, + leading_silence_seconds: f32, + trailing_silence_seconds: f32, + integrated_lufs: Option, + sample_peak: f64, + true_peak: f64, + duration_seconds: f32, + energy_curve: Vec, + beat_positions_seconds: Vec, + sections: Vec, + musical_key: Option, + key_confidence: Option, + vocal_profile: AnalysisAvailability, + analysis_backend: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct DetectedSection { + kind: SectionKind, + start_seconds: f32, + end_seconds: f32, + energy: f32, + confidence: f32, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum SectionKind { + Intro, + BuildUp, + Breakdown, + Drop, + Chorus, + Outro, + Main, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum AnalysisAvailability { + Unavailable { reason: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct CachedTrackAnalysis { + file_len: u64, + modified_nanos: u128, + analyzer_version: String, + analysis: TrackAnalysis, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct AnalysisCache { + schema_version: u32, + tracks: BTreeMap, +} + +impl Default for AnalysisCache { + fn default() -> Self { + Self { + schema_version: ENGINE_SCHEMA_VERSION, + tracks: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug)] +struct PlannedTrack { + track: DjMixTrack, + analysis: TrackAnalysis, + speed_ratio: f32, + gain: f32, + section_start_seconds: f32, + section_end_seconds: f32, + section_reason: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct MixReport { + schema_version: u32, + engine_version: String, + analyzer_version: String, + output_path: String, + requested_target_minutes: f32, + planned_duration_seconds: f32, + rendered_duration_seconds: f32, + integrated_lufs: Option, + sample_peak_dbfs: Option, + true_peak_dbfs: Option, + timings_ms: RenderTimings, + tracks: Vec, + transitions: Vec, + warnings: Vec, + professional_tools: ProfessionalToolReport, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct ProfessionalToolReport { + essentia: String, + rubber_band: String, + demucs: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct RenderTimings { + analysis: u128, + planning: u128, + rendering: u128, + finalization: u128, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct TrackDiagnostic { + title: String, + path: String, + bpm: f32, + bpm_confidence: f32, + beat_offset_seconds: f32, + downbeat_offset_seconds: f32, + integrated_lufs: Option, + true_peak_dbfs: Option, + musical_key: Option, + key_confidence: Option, + vocal_analysis: String, + analysis_backend: String, + selected_section: String, + section_start_seconds: f32, + section_end_seconds: f32, + section_reason: String, + speed_ratio: f32, + gain_db: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct TransitionDiagnostic { + from_title: String, + to_title: String, + style: String, + phrase_bars: u32, + duration_seconds: f32, + effective_bpm: f32, + beat_alignment_error_ms: f32, + peak_reduction_db: f32, + decisions: Vec, + warnings: Vec, +} + +#[derive(Clone, Debug, Default)] +struct OutputMeasurement { + duration_seconds: f32, + integrated_lufs: Option, + sample_peak_dbfs: Option, + true_peak_dbfs: Option, +} + +struct Mp3StreamWriter { + file: BufWriter, + encoder: Mp3Encoder, + pcm: Vec, + measurement_pcm: Vec, + loudness: EbuR128, + flush_samples: usize, + frames_written: u64, +} + +impl Mp3StreamWriter { + fn create(path: &Path, bitrate_kbps: u32) -> Result { + let file = File::create(path) + .with_context(|| format!("Failed to create MP3 file: {}", path.display()))?; + let encoder = Mp3Encoder::new( + Mp3EncoderConfig::new() + .sample_rate(OUTPUT_SAMPLE_RATE) + .bitrate(bitrate_kbps) + .channels(OUTPUT_CHANNELS as u8) + .stereo_mode(StereoMode::JointStereo), + ) + .map_err(|error| anyhow!("Failed to initialize MP3 encoder: {error}"))?; + let flush_samples = encoder.samples_per_frame() * 16; + let loudness = EbuR128::new( + OUTPUT_CHANNELS as u32, + OUTPUT_SAMPLE_RATE, + Mode::I | Mode::SAMPLE_PEAK | Mode::TRUE_PEAK, + ) + .map_err(|error| anyhow!("Failed to initialize EBU R128 output meter: {error}"))?; + + Ok(Self { + file: BufWriter::new(file), + encoder, + pcm: Vec::with_capacity(flush_samples), + measurement_pcm: Vec::with_capacity(flush_samples), + loudness, + flush_samples, + frames_written: 0, + }) + } + + fn write_frame(&mut self, frame: [f32; 2]) -> Result<()> { + let frame = limit_frame(frame); + self.pcm.push(float_to_i16(frame[0])); + self.pcm.push(float_to_i16(frame[1])); + self.measurement_pcm.push(frame[0]); + self.measurement_pcm.push(frame[1]); + self.frames_written += 1; + if self.pcm.len() >= self.flush_samples { + self.flush_pcm()?; + } + Ok(()) + } + + fn flush_pcm(&mut self) -> Result<()> { + if self.pcm.is_empty() { + return Ok(()); + } + self.loudness + .add_frames_f32(&self.measurement_pcm) + .map_err(|error| anyhow!("EBU R128 output measurement failed: {error}"))?; + self.measurement_pcm.clear(); + let frames = self + .encoder + .encode_interleaved(&self.pcm) + .map_err(|error| anyhow!("MP3 encoding failed: {error}"))?; + self.pcm.clear(); + for frame in frames { + self.file.write_all(&frame)?; + } + Ok(()) + } + + fn finish(mut self) -> Result { + self.flush_pcm()?; + let tail = self + .encoder + .finish() + .map_err(|error| anyhow!("Failed to finish MP3 encoding: {error}"))?; + self.file.write_all(&tail)?; + self.file.flush()?; + + Ok(OutputMeasurement { + duration_seconds: self.frames_written as f32 / OUTPUT_SAMPLE_RATE as f32, + integrated_lufs: self + .loudness + .loudness_global() + .ok() + .filter(|value| value.is_finite()), + sample_peak_dbfs: maximum_peak_dbfs(&self.loudness, false), + true_peak_dbfs: maximum_peak_dbfs(&self.loudness, true), + }) + } +} + +pub(crate) fn export_mix( + request: ExportRequest, + sender: Sender, + cancel: Arc, +) { + lower_worker_priority(); + let output_path = ensure_mp3_extension(&request.output_path); + let temporary_path = temporary_output_path(&output_path); + let report_path = report_output_path(&output_path); + let temporary_report_path = temporary_output_path(&report_path); + let result = export_mix_inner( + &request, + &temporary_path, + &temporary_report_path, + &output_path, + &sender, + &cancel, + ); + + match result { + Ok((measurement, summary)) => { + if cancel.load(AtomicOrdering::Relaxed) { + cleanup_temporary_files(&temporary_path, &temporary_report_path); + let _ = sender.send(DjMixEvent::Cancelled); + return; + } + let _ = fs::remove_file(&output_path); + let _ = fs::remove_file(&report_path); + let audio_result = fs::rename(&temporary_path, &output_path); + let report_result = fs::rename(&temporary_report_path, &report_path); + match (audio_result, report_result) { + (Ok(()), Ok(())) => { + let _ = sender.send(DjMixEvent::Completed { + output_path, + report_path, + track_count: request.tracks.len(), + duration_seconds: measurement.duration_seconds, + integrated_lufs: measurement.integrated_lufs, + true_peak_dbfs: measurement.true_peak_dbfs, + diagnostics_summary: summary, + }); + } + (audio, report) => { + cleanup_temporary_files(&temporary_path, &temporary_report_path); + let _ = fs::remove_file(&output_path); + let _ = fs::remove_file(&report_path); + let _ = sender.send(DjMixEvent::Failed(format!( + "Failed to finalize DJ mix: audio={audio:?}, diagnostics={report:?}" + ))); + } + } + } + Err(error) => { + cleanup_temporary_files(&temporary_path, &temporary_report_path); + if cancel.load(AtomicOrdering::Relaxed) { + let _ = sender.send(DjMixEvent::Cancelled); + } else { + let _ = sender.send(DjMixEvent::Failed(error.to_string())); + } + } + } +} + +fn export_mix_inner( + request: &ExportRequest, + temporary_path: &Path, + temporary_report_path: &Path, + final_output_path: &Path, + sender: &Sender, + cancel: &AtomicBool, +) -> Result<(OutputMeasurement, String)> { + if request.tracks.len() < 2 { + return Err(anyhow!("DJ mix requires at least two tracks.")); + } + validate_options(request.options)?; + if let Some(parent) = temporary_path.parent() { + fs::create_dir_all(parent)?; + } + + let tools = ProfessionalToolchain::detect(request.options.professional_tools); + send_progress(sender, tools.summary(), 0.01); + + let mut timings = RenderTimings::default(); + let analysis_started = Instant::now(); + let mut cache = load_analysis_cache(); + let mut analyzed = Vec::with_capacity(request.tracks.len()); + for (index, track) in request.tracks.iter().enumerate() { + ensure_not_cancelled(cancel)?; + send_progress( + sender, + format!("Analyzing {}", track.title), + index as f32 / (request.tracks.len() as f32 * 2.5), + ); + let analysis = cached_or_analyze(track, &mut cache, &tools, cancel)?; + analyzed.push((track.clone(), analysis)); + } + save_analysis_cache(&cache); + timings.analysis = analysis_started.elapsed().as_millis(); + + let planning_started = Instant::now(); + let planned = plan_tracks(analyzed, request.options)?; + let transitions = plan_transition_diagnostics(&planned, request.options, &tools); + timings.planning = planning_started.elapsed().as_millis(); + + let rendering_started = Instant::now(); + let mut writer = Mp3StreamWriter::create(temporary_path, request.options.bitrate_kbps)?; + let custom_bridge = load_custom_bridge(request, sender, cancel)?; + render_planned_mix( + &planned, + request.options, + custom_bridge.as_ref(), + &tools, + &mut writer, + sender, + cancel, + )?; + ensure_not_cancelled(cancel)?; + timings.rendering = rendering_started.elapsed().as_millis(); + + send_progress(sender, "Finalizing MP3 and diagnostics".to_owned(), 0.99); + let finalization_started = Instant::now(); + let measurement = writer.finish()?; + timings.finalization = finalization_started.elapsed().as_millis(); + let report = build_report( + final_output_path, + &planned, + transitions, + request.options, + measurement.clone(), + timings.clone(), + tools.report(), + ); + let report_bytes = serde_json::to_vec_pretty(&report) + .context("Failed to serialize DJ transition diagnostics")?; + fs::write(temporary_report_path, report_bytes).with_context(|| { + format!( + "Failed to write diagnostics: {}", + temporary_report_path.display() + ) + })?; + + let summary = format!( + "{:.1} min, {} transitions, {:.1} LUFS, {:.1} dBTP", + measurement.duration_seconds / 60.0, + report.transitions.len(), + measurement.integrated_lufs.unwrap_or(f64::NAN), + measurement.true_peak_dbfs.unwrap_or(f64::NAN) + ); + Ok((measurement, summary)) +} + +fn validate_options(options: DjMixOptions) -> Result<()> { + if !matches!(options.transition_bars, 8 | 16 | 32) { + return Err(anyhow!("DJ transition must be 8, 16, or 32 bars.")); + } + if !(1.0..=180.0).contains(&options.target_minutes) { + return Err(anyhow!( + "DJ mix target length must be between 1 and 180 minutes." + )); + } + if !matches!(options.bitrate_kbps, 192 | 256 | 320) { + return Err(anyhow!("DJ MP3 bitrate must be 192, 256, or 320 kbps.")); + } + if !(0.25..=60.0).contains(&options.bridge_loop_seconds) { + return Err(anyhow!( + "Custom bridge loop length must be between 0.25 and 60 seconds." + )); + } + if !(0.15..=1.0).contains(&options.bridge_level) { + return Err(anyhow!("Bridge level must be between 0.15 and 1.0.")); + } + Ok(()) +} + +struct RenderedSection { + mix: Vec<[f32; 2]>, + stems: Option, +} + +struct StemSet { + drums: Vec<[f32; 2]>, + bass: Vec<[f32; 2]>, + other: Vec<[f32; 2]>, + vocals: Vec<[f32; 2]>, +} + +impl StemSet { + fn frame(&self, stem: StemKind, index: usize) -> [f32; 2] { + let frames = match stem { + StemKind::Drums => &self.drums, + StemKind::Bass => &self.bass, + StemKind::Other => &self.other, + StemKind::Vocals => &self.vocals, + }; + frames.get(index).copied().unwrap_or([0.0, 0.0]) + } + + fn target_len(&self) -> usize { + self.drums + .len() + .max(self.bass.len()) + .max(self.other.len()) + .max(self.vocals.len()) + } + + fn normalize_lengths(&mut self) { + let target = self.target_len(); + fit_frames(&mut self.drums, target); + fit_frames(&mut self.bass, target); + fit_frames(&mut self.other, target); + fit_frames(&mut self.vocals, target); + } + + fn recombine(&self) -> Vec<[f32; 2]> { + (0..self.target_len()) + .map(|index| { + let drums = self.frame(StemKind::Drums, index); + let bass = self.frame(StemKind::Bass, index); + let other = self.frame(StemKind::Other, index); + let vocals = self.frame(StemKind::Vocals, index); + [ + drums[0] + bass[0] + other[0] + vocals[0], + drums[1] + bass[1] + other[1] + vocals[1], + ] + }) + .collect() + } +} + +#[derive(Clone, Copy)] +enum StemKind { + Drums, + Bass, + Other, + Vocals, +} + +fn render_planned_mix( + tracks: &[PlannedTrack], + options: DjMixOptions, + custom_bridge: Option<&CustomBridgeAudio>, + tools: &ProfessionalToolchain, + writer: &mut Mp3StreamWriter, + sender: &Sender, + cancel: &AtomicBool, +) -> Result<()> { + let first = tracks + .first() + .ok_or_else(|| anyhow!("DJ plan contains no tracks."))?; + send_progress(sender, format!("Preparing {}", first.track.title), 0.40); + let mut current_audio = render_planned_section(first, options, tools, sender, cancel)?; + let planned_transitions = planned_transition_frame_counts(tracks, options); + + for pair_index in 0..tracks.len() - 1 { + ensure_not_cancelled(cancel)?; + let current = &tracks[pair_index]; + let next = &tracks[pair_index + 1]; + send_progress( + sender, + format!( + "{} {} → {}", + match options.style { + DjMixStyle::Crossfade => "Crossfading", + DjMixStyle::SmartDj => "Planning human transition", + }, + current.track.title, + next.track.title + ), + 0.45 + pair_index as f32 / ((tracks.len() - 1) as f32 * 2.0), + ); + + let next_audio = render_planned_section(next, options, tools, sender, cancel)?; + let requested_transition = planned_transitions[pair_index]; + let following_transition = planned_transitions + .get(pair_index + 1) + .copied() + .unwrap_or(0); + let next_budget = incoming_transition_budget( + next_audio.mix.len(), + requested_transition, + following_transition, + ); + let overlap = requested_transition + .min(current_audio.mix.len()) + .min(next_budget); + if overlap == 0 { + write_frames(¤t_audio.mix, current.gain, writer, cancel)?; + current_audio = next_audio; + continue; + } + + let body_len = current_audio.mix.len().saturating_sub(overlap); + write_frames(¤t_audio.mix[..body_len], current.gain, writer, cancel)?; + match options.style { + DjMixStyle::Crossfade => write_crossfade_transition( + ¤t_audio.mix[body_len..], + &next_audio.mix[..overlap], + current.gain, + next.gain, + options.bass_swap, + writer, + cancel, + )?, + DjMixStyle::SmartDj => write_performance_transition( + ¤t_audio.mix[body_len..], + &next_audio.mix[..overlap], + current_audio.stems.as_ref(), + body_len, + next_audio.stems.as_ref(), + current, + next, + options.bass_swap, + options.bridge_mode, + options.bridge_level, + custom_bridge, + writer, + cancel, + )?, + } + current_audio.mix = next_audio.mix[overlap..].to_vec(); + current_audio.stems = next_audio.stems.map(|mut stems| { + stems.drums = stems.drums.into_iter().skip(overlap).collect(); + stems.bass = stems.bass.into_iter().skip(overlap).collect(); + stems.other = stems.other.into_iter().skip(overlap).collect(); + stems.vocals = stems.vocals.into_iter().skip(overlap).collect(); + stems + }); + } + + let last = tracks + .last() + .ok_or_else(|| anyhow!("DJ plan contains no tracks."))?; + write_frames(¤t_audio.mix, last.gain, writer, cancel)?; + Ok(()) +} + +fn render_planned_section( + track: &PlannedTrack, + options: DjMixOptions, + tools: &ProfessionalToolchain, + sender: &Sender, + cancel: &AtomicBool, +) -> Result { + send_progress(sender, format!("Decoding {}", track.track.title), 0.42); + let decoded = decode_section( + &track.track.path, + track.section_start_seconds, + track.section_end_seconds, + cancel, + )?; + + let mut stems = if options.style == DjMixStyle::SmartDj + && options.professional_tools + && options.stem_separation + && tools.demucs.is_some() + { + send_progress( + sender, + format!( + "Separating drums, bass, vocals and music: {}", + track.track.title + ), + 0.43, + ); + match separate_with_demucs(track, &decoded, tools, cancel) { + Ok(stems) => stems, + Err(error) if cancel.load(AtomicOrdering::Relaxed) => return Err(error), + Err(_) => None, + } + } else { + None + }; + + if options.style == DjMixStyle::Crossfade || (track.speed_ratio - 1.0).abs() < 0.0005 { + if let Some(stems) = stems.as_mut() { + stems.normalize_lengths(); + } + return Ok(RenderedSection { + mix: stems.as_ref().map(StemSet::recombine).unwrap_or(decoded), + stems, + }); + } + + ensure_not_cancelled(cancel)?; + let cache_tag = section_cache_tag(track)?; + if let Some(stem_set) = stems.as_mut() { + send_progress( + sender, + format!("Studio tempo match per stem: {}", track.track.title), + 0.44, + ); + stem_set.drums = stretch_audio( + &stem_set.drums, + track.speed_ratio, + tools, + &format!("{cache_tag}-drums"), + cancel, + )?; + stem_set.bass = stretch_audio( + &stem_set.bass, + track.speed_ratio, + tools, + &format!("{cache_tag}-bass"), + cancel, + )?; + stem_set.other = stretch_audio( + &stem_set.other, + track.speed_ratio, + tools, + &format!("{cache_tag}-other"), + cancel, + )?; + stem_set.vocals = stretch_audio( + &stem_set.vocals, + track.speed_ratio, + tools, + &format!("{cache_tag}-vocals"), + cancel, + )?; + stem_set.normalize_lengths(); + let mix = stem_set.recombine(); + if mix.is_empty() { + return Err(anyhow!("Tempo matching failed for {}", track.track.title)); + } + return Ok(RenderedSection { mix, stems }); + } + + let output = stretch_audio(&decoded, track.speed_ratio, tools, &cache_tag, cancel)?; + ensure_not_cancelled(cancel)?; + if output.is_empty() { + return Err(anyhow!( + "Pitch-preserving time stretch failed for {}", + track.track.title + )); + } + Ok(RenderedSection { + mix: output, + stems: None, + }) +} + +fn section_cache_tag(track: &PlannedTrack) -> Result { + let start = track.section_start_seconds.to_le_bytes(); + let end = track.section_end_seconds.to_le_bytes(); + let key = file_cache_key( + &track.track.path, + &[ENGINE_VERSION.as_bytes(), &start, &end], + )?; + Ok(format!("{key:016x}-{:.6}", track.speed_ratio)) +} + +fn separate_with_demucs( + track: &PlannedTrack, + decoded: &[[f32; 2]], + tools: &ProfessionalToolchain, + cancel: &AtomicBool, +) -> Result> { + let Some(demucs) = tools.demucs.as_ref() else { + return Ok(None); + }; + let cache_tag = section_cache_tag(track)?; + let cache_dir = professional_cache_dir().join("demucs").join(cache_tag); + fs::create_dir_all(&cache_dir)?; + let input_path = cache_dir.join("section.wav"); + if !input_path.is_file() { + write_pcm16_wav(&input_path, decoded)?; + } + + let mut stem_paths = locate_demucs_stems(&cache_dir); + if stem_paths.is_none() { + let mut command = demucs.command(); + command + .arg("-n") + .arg("htdemucs") + .arg("--out") + .arg(&cache_dir) + .arg("--shifts") + .arg("0") + .arg("--overlap") + .arg("0.25") + .arg(&input_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = run_command_cancellable(command, cancel)?; + if !status.success() { + return Ok(None); + } + stem_paths = locate_demucs_stems(&cache_dir); + } + let Some([drums_path, bass_path, other_path, vocals_path]) = stem_paths else { + return Ok(None); + }; + Ok(Some(StemSet { + drums: decode_entire_audio(&drums_path, cancel)?, + bass: decode_entire_audio(&bass_path, cancel)?, + other: decode_entire_audio(&other_path, cancel)?, + vocals: decode_entire_audio(&vocals_path, cancel)?, + })) +} + +fn locate_demucs_stems(root: &Path) -> Option<[PathBuf; 4]> { + Some([ + find_file_named(root, "drums.wav")?, + find_file_named(root, "bass.wav")?, + find_file_named(root, "other.wav")?, + find_file_named(root, "vocals.wav")?, + ]) +} + +fn find_file_named(root: &Path, file_name: &str) -> Option { + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_file_named(&path, file_name) { + return Some(found); + } + } else if path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(file_name)) + { + return Some(path); + } + } + None +} + +fn stretch_audio( + frames: &[[f32; 2]], + speed_ratio: f32, + tools: &ProfessionalToolchain, + cache_tag: &str, + cancel: &AtomicBool, +) -> Result> { + ensure_not_cancelled(cancel)?; + if frames.is_empty() || (speed_ratio - 1.0).abs() < 0.0005 { + return Ok(frames.to_vec()); + } + if let Some(rubber_band) = tools.rubber_band.as_ref() { + match stretch_with_rubber_band(frames, speed_ratio, rubber_band, cache_tag, cancel) { + Ok(output) if !output.is_empty() => return Ok(output), + Err(error) if cancel.load(AtomicOrdering::Relaxed) => return Err(error), + _ => {} + } + } + ensure_not_cancelled(cancel)?; + Ok(pitch_preserving_stretch(frames, speed_ratio)) +} + +fn stretch_with_rubber_band( + frames: &[[f32; 2]], + speed_ratio: f32, + rubber_band: &ExternalCommand, + cache_tag: &str, + cancel: &AtomicBool, +) -> Result> { + let cache_dir = professional_cache_dir().join("rubber-band"); + fs::create_dir_all(&cache_dir)?; + let safe_tag = cache_tag + .chars() + .map(|value| { + if value.is_ascii_alphanumeric() || matches!(value, '-' | '_') { + value + } else { + '_' + } + }) + .collect::(); + let input_path = cache_dir.join(format!("{safe_tag}-input.wav")); + let output_path = cache_dir.join(format!("{safe_tag}-output.wav")); + if !output_path.is_file() { + write_pcm16_wav(&input_path, frames)?; + let mut command = rubber_band.command(); + command + .arg("-3") + .arg("--centre-focus") + .arg("--quiet") + .arg("--tempo") + .arg(format!("{speed_ratio:.8}")) + .arg(&input_path) + .arg(&output_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = run_command_cancellable(command, cancel)?; + if !status.success() { + return Err(anyhow!("Rubber Band exited with {status}")); + } + } + decode_entire_audio(&output_path, cancel) +} + +fn write_pcm16_wav(path: &Path, frames: &[[f32; 2]]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let data_bytes = frames.len() as u32 * OUTPUT_CHANNELS as u32 * 2; + let byte_rate = OUTPUT_SAMPLE_RATE * OUTPUT_CHANNELS as u32 * 2; + let block_align = OUTPUT_CHANNELS * 2; + let mut file = BufWriter::new(File::create(path)?); + file.write_all(b"RIFF")?; + file.write_all(&(36u32.saturating_add(data_bytes)).to_le_bytes())?; + file.write_all(b"WAVEfmt ")?; + file.write_all(&16u32.to_le_bytes())?; + file.write_all(&1u16.to_le_bytes())?; + file.write_all(&OUTPUT_CHANNELS.to_le_bytes())?; + file.write_all(&OUTPUT_SAMPLE_RATE.to_le_bytes())?; + file.write_all(&byte_rate.to_le_bytes())?; + file.write_all(&block_align.to_le_bytes())?; + file.write_all(&16u16.to_le_bytes())?; + file.write_all(b"data")?; + file.write_all(&data_bytes.to_le_bytes())?; + for frame in frames { + file.write_all(&float_to_i16(frame[0]).to_le_bytes())?; + file.write_all(&float_to_i16(frame[1]).to_le_bytes())?; + } + file.flush()?; + Ok(()) +} + +fn decode_entire_audio(path: &Path, cancel: &AtomicBool) -> Result> { + let file = File::open(path) + .with_context(|| format!("Failed to open audio file: {}", path.display()))?; + let decoder = Decoder::new(BufReader::new(file)) + .with_context(|| format!("Failed to decode audio file: {}", path.display()))?; + let mut source = + UniformSourceIterator::<_, f32>::new(decoder, OUTPUT_CHANNELS, OUTPUT_SAMPLE_RATE); + let mut output = Vec::new(); + let mut index = 0usize; + while let Some(frame) = read_stereo_frame(&mut source) { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + output.push(frame); + index += 1; + } + Ok(output) +} + +fn fit_frames(frames: &mut Vec<[f32; 2]>, target: usize) { + if frames.len() > target { + frames.truncate(target); + } else if frames.len() < target { + frames.resize(target, [0.0, 0.0]); + } +} + +fn decode_section( + path: &Path, + start_seconds: f32, + end_seconds: f32, + cancel: &AtomicBool, +) -> Result> { + let file = File::open(path) + .with_context(|| format!("Failed to open audio file: {}", path.display()))?; + let decoder = Decoder::new(BufReader::new(file)) + .with_context(|| format!("Failed to decode audio file: {}", path.display()))?; + let mut source = + UniformSourceIterator::<_, f32>::new(decoder, OUTPUT_CHANNELS, OUTPUT_SAMPLE_RATE); + let start_frame = (start_seconds.max(0.0) * OUTPUT_SAMPLE_RATE as f32).round() as usize; + let end_frame = (end_seconds.max(start_seconds) * OUTPUT_SAMPLE_RATE as f32).round() as usize; + let frame_count = end_frame.saturating_sub(start_frame); + for index in 0..start_frame { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + if read_stereo_frame(&mut source).is_none() { + break; + } + } + let mut output = Vec::with_capacity(frame_count); + for index in 0..frame_count { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + let Some(frame) = read_stereo_frame(&mut source) else { + break; + }; + output.push(frame); + } + if output.is_empty() { + return Err(anyhow!( + "Selected section is unavailable: {}", + path.display() + )); + } + Ok(output) +} + +fn write_frames( + frames: &[[f32; 2]], + gain: f32, + writer: &mut Mp3StreamWriter, + cancel: &AtomicBool, +) -> Result<()> { + for (index, frame) in frames.iter().enumerate() { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + writer.write_frame(multiply_frame(*frame, gain))?; + } + Ok(()) +} + +fn write_crossfade_transition( + outgoing: &[[f32; 2]], + incoming: &[[f32; 2]], + outgoing_gain: f32, + incoming_gain: f32, + bass_swap: bool, + writer: &mut Mp3StreamWriter, + cancel: &AtomicBool, +) -> Result<()> { + let frames = outgoing.len().min(incoming.len()); + let peak_scale = transition_peak_scale( + &outgoing[..frames], + &incoming[..frames], + outgoing_gain, + incoming_gain, + ); + let mut outgoing_filter = LowPassStereo::new(180.0); + let mut incoming_filter = LowPassStereo::new(180.0); + for index in 0..frames { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + let progress = normalized_progress(index, frames); + let out_fade = ((1.0 - progress) * std::f32::consts::FRAC_PI_2).sin(); + let in_fade = (progress * std::f32::consts::FRAC_PI_2).sin(); + let mut out = multiply_frame(outgoing[index], outgoing_gain * peak_scale); + let mut input = multiply_frame(incoming[index], incoming_gain * peak_scale); + if bass_swap { + apply_bass_swap( + &mut out, + &mut input, + progress, + &mut outgoing_filter, + &mut incoming_filter, + ); + } + writer.write_frame([ + out[0] * out_fade + input[0] * in_fade, + out[1] * out_fade + input[1] * in_fade, + ])?; + } + Ok(()) +} + +fn write_performance_transition( + outgoing: &[[f32; 2]], + incoming: &[[f32; 2]], + outgoing_stems: Option<&StemSet>, + outgoing_stem_offset: usize, + incoming_stems: Option<&StemSet>, + current: &PlannedTrack, + next: &PlannedTrack, + bass_swap: bool, + bridge_mode: DjBridgeMode, + bridge_level: f32, + custom_bridge: Option<&CustomBridgeAudio>, + writer: &mut Mp3StreamWriter, + cancel: &AtomicBool, +) -> Result<()> { + let frames = outgoing.len().min(incoming.len()); + if frames == 0 { + return Ok(()); + } + + let peak_scale = transition_peak_scale( + &outgoing[..frames], + &incoming[..frames], + current.gain, + next.gain, + ); + let bpm = ((current.analysis.bpm + next.analysis.bpm) * 0.5).clamp(MIN_BPM, MAX_BPM); + let beat_frames = ((60.0 / bpm) * OUTPUT_SAMPLE_RATE as f32).round().max(1.0) as usize; + // Use the complete overlap as one evolving phrase. Repeating a one-bar fragment + // produces the mechanical buzzing/pumping associated with cheap auto-mixers. + let loop_frames = frames; + let strong_change = transition_should_hit_hard(current, next); + let stems_available = outgoing_stems.is_some() && incoming_stems.is_some(); + let recipe = TransitionRecipe::resolve( + bridge_mode, + transition_seed(current, next), + strong_change, + stems_available, + keys_are_compatible(current, next), + ); + + let mut outgoing_sweep = LowPassStereo::new(18_000.0); + let mut incoming_sweep = LowPassStereo::new(320.0); + let mut harmonic_sweep = LowPassStereo::new(7_500.0); + let mut delay = StereoDelay::new((beat_frames / 2).max(1), 0.42); + let mut percussion = TransitionPercussion::new(bpm, transition_seed(current, next)); + let mut outgoing_vocal_guard = CenterVocalGuard::new(); + let mut incoming_vocal_guard = CenterVocalGuard::new(); + let mut outgoing_bed_filter = LowPassStereo::new(1_100.0); + let mut incoming_bed_filter = LowPassStereo::new(1_100.0); + let vocal_plan = plan_vocal_handoff(&outgoing[..frames], &incoming[..frames]); + + for index in 0..frames { + if index % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + let progress = normalized_progress(index, frames); + if index % 64 == 0 { + outgoing_sweep.set_cutoff(18_000.0 - smoothstep(0.28, 0.78, progress) * 16_900.0); + incoming_sweep.set_cutoff(300.0 + smoothstep(0.58, 0.90, progress) * 17_600.0); + harmonic_sweep.set_cutoff(2_800.0 + smoothstep(0.25, 0.70, progress) * 9_000.0); + } + + let mut mixed = if let (Some(out_stems), Some(in_stems)) = (outgoing_stems, incoming_stems) + { + let gates = recipe.stem_gates(progress, strong_change, bass_swap); + mix_stem_transition_frame( + out_stems, + outgoing_stem_offset + index, + in_stems, + index, + gates, + current.gain * peak_scale, + next.gain * peak_scale, + ) + } else { + let mut out = multiply_frame(outgoing[index], current.gain * peak_scale); + let mut input = multiply_frame(incoming[index], next.gain * peak_scale); + let (outgoing_vocal_duck, incoming_vocal_duck) = + vocal_plan.duck_amounts(index, progress); + out = outgoing_vocal_guard.process(out, outgoing_vocal_duck); + input = incoming_vocal_guard.process(input, incoming_vocal_duck); + out = mix_frame( + out, + outgoing_sweep.process(out), + smoothstep(0.28, 0.72, progress) * 0.88, + ); + input = mix_frame( + incoming_sweep.process(input), + input, + smoothstep(0.66, 0.90, progress), + ); + let out_gate = + 1.0 - smoothstep(0.42, if strong_change { 0.66 } else { 0.74 }, progress); + let in_gate = smoothstep(if strong_change { 0.72 } else { 0.66 }, 0.88, progress); + [ + out[0] * out_gate + input[0] * in_gate, + out[1] * out_gate + input[1] * in_gate, + ] + }; + + let bed_handoff = smoothstep(0.40, 0.60, progress); + let out_bed_gain = ((1.0 - bed_handoff) * std::f32::consts::FRAC_PI_2).sin(); + let in_bed_gain = (bed_handoff * std::f32::consts::FRAC_PI_2).sin(); + let continuity_bed = + if let (Some(out_stems), Some(in_stems)) = (outgoing_stems, incoming_stems) { + let out_drums = out_stems.frame(StemKind::Drums, outgoing_stem_offset + index); + let out_other = out_stems.frame(StemKind::Other, outgoing_stem_offset + index); + let in_drums = in_stems.frame(StemKind::Drums, index); + let in_other = in_stems.frame(StemKind::Other, index); + [ + (out_drums[0] * 0.62 + out_other[0] * 0.38) + * current.gain + * peak_scale + * out_bed_gain + + (in_drums[0] * 0.62 + in_other[0] * 0.38) + * next.gain + * peak_scale + * in_bed_gain, + (out_drums[1] * 0.62 + out_other[1] * 0.38) + * current.gain + * peak_scale + * out_bed_gain + + (in_drums[1] * 0.62 + in_other[1] * 0.38) + * next.gain + * peak_scale + * in_bed_gain, + ] + } else { + let out_bed = outgoing_bed_filter + .process(multiply_frame(outgoing[index], current.gain * peak_scale)); + let in_bed = incoming_bed_filter + .process(multiply_frame(incoming[index], next.gain * peak_scale)); + [ + out_bed[0] * out_bed_gain + in_bed[0] * in_bed_gain, + out_bed[1] * out_bed_gain + in_bed[1] * in_bed_gain, + ] + }; + mixed[0] += continuity_bed[0] * MIN_TRANSITION_BED_GAIN; + mixed[1] += continuity_bed[1] * MIN_TRANSITION_BED_GAIN; + + let deck_bridge = transition_deck_bridge( + recipe, + outgoing, + incoming, + outgoing_stems, + outgoing_stem_offset, + incoming_stems, + index, + frames, + loop_frames, + &mut harmonic_sweep, + ); + let custom = custom_bridge + .filter(|_| bridge_mode == DjBridgeMode::Custom) + .map(|audio| audio.render(index, progress)); + let bridge_frame = custom.unwrap_or(deck_bridge); + let bridge_gate = recipe.bridge_gate(progress, strong_change) * bridge_level; + mixed[0] += bridge_frame[0] * bridge_gate; + mixed[1] += bridge_frame[1] * bridge_gate; + + let percussion_frame = percussion.render(index, progress, strong_change); + let percussion_gate = recipe.percussion_gate(progress); + mixed[0] += percussion_frame[0] * percussion_gate; + mixed[1] += percussion_frame[1] * percussion_gate; + + if recipe.uses_echo_out() { + let echo_source = multiply_frame(outgoing[index], current.gain * peak_scale); + let delayed = delay.process(echo_source); + let echo_gate = + smoothstep(0.36, 0.55, progress) * (1.0 - smoothstep(0.76, 0.90, progress)); + mixed[0] += delayed[0] * echo_gate * 0.46; + mixed[1] += delayed[1] * echo_gate * 0.46; + } + + if recipe.has_drop_cut() && progress > 0.69 && progress < 0.735 { + let down = 1.0 - smoothstep(0.69, 0.715, progress); + let up = smoothstep(0.715, 0.735, progress); + let cut = MIN_DROP_CUT_GAIN + (1.0 - MIN_DROP_CUT_GAIN) * down.max(up).clamp(0.0, 1.0); + mixed[0] *= cut; + mixed[1] *= cut; + } + + writer.write_frame(limit_frame(mixed))?; + } + Ok(()) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let x = ((value - edge0) / (edge1 - edge0).max(1.0e-6)).clamp(0.0, 1.0); + x * x * (3.0 - 2.0 * x) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TransitionRecipe { + DrumSwap, + HarmonicBridge, + EchoDrop, + StemMashup, + CustomBridge, +} + +impl TransitionRecipe { + fn resolve( + mode: DjBridgeMode, + seed: u32, + strong: bool, + stems_available: bool, + harmonic_match: bool, + ) -> Self { + match mode { + DjBridgeMode::DrumSwap => Self::DrumSwap, + DjBridgeMode::HarmonicBridge => Self::HarmonicBridge, + DjBridgeMode::EchoDrop => Self::EchoDrop, + DjBridgeMode::StemMashup => Self::StemMashup, + DjBridgeMode::Custom => Self::CustomBridge, + DjBridgeMode::Auto if strong => Self::EchoDrop, + DjBridgeMode::Auto if stems_available && harmonic_match => { + if seed % 2 == 0 { + Self::HarmonicBridge + } else { + Self::StemMashup + } + } + DjBridgeMode::Auto if stems_available => Self::DrumSwap, + DjBridgeMode::Auto => match seed % 3 { + 0 => Self::DrumSwap, + 1 => Self::EchoDrop, + _ => Self::HarmonicBridge, + }, + } + } + + fn stem_gates(self, progress: f32, strong: bool, bass_swap: bool) -> StemGates { + let bass_point = if bass_swap { 0.54 } else { 0.66 }; + match self { + Self::DrumSwap => StemGates { + out_drums: 1.0 - smoothstep(0.34, 0.58, progress), + out_bass: 1.0 - smoothstep(bass_point - 0.06, bass_point, progress), + out_other: 1.0 - smoothstep(0.58, 0.80, progress), + out_vocals: 1.0 - smoothstep(0.32, 0.52, progress), + in_drums: smoothstep(0.22, 0.46, progress), + in_bass: smoothstep(bass_point, bass_point + 0.10, progress), + in_other: smoothstep(0.62, 0.84, progress), + in_vocals: smoothstep(0.82, 0.96, progress), + }, + Self::HarmonicBridge => StemGates { + out_drums: 1.0 - smoothstep(0.42, 0.68, progress), + out_bass: 1.0 - smoothstep(0.46, 0.58, progress), + out_other: 1.0 - smoothstep(0.66, 0.88, progress), + out_vocals: 1.0 - smoothstep(0.26, 0.46, progress), + in_drums: smoothstep(0.42, 0.68, progress), + in_bass: smoothstep(0.58, 0.70, progress), + in_other: smoothstep(0.58, 0.86, progress), + in_vocals: smoothstep(0.84, 0.98, progress), + }, + Self::EchoDrop => StemGates { + out_drums: 1.0 - smoothstep(0.42, 0.68, progress), + out_bass: 1.0 - smoothstep(0.46, 0.62, progress), + out_other: 1.0 - smoothstep(0.50, 0.70, progress), + out_vocals: 1.0 - smoothstep(0.38, 0.58, progress), + in_drums: smoothstep(0.72, 0.79, progress), + in_bass: smoothstep(0.74, 0.82, progress), + in_other: smoothstep(0.74, 0.84, progress), + in_vocals: smoothstep(0.84, 0.96, progress), + }, + Self::StemMashup | Self::CustomBridge => StemGates { + out_drums: 1.0 - smoothstep(0.48, 0.72, progress), + out_bass: 1.0 - smoothstep(0.48, 0.58, progress), + out_other: 1.0 - smoothstep(0.68, 0.88, progress), + out_vocals: 1.0 - smoothstep(0.30, 0.50, progress), + in_drums: smoothstep(0.26, 0.48, progress), + in_bass: smoothstep(0.58, 0.70, progress), + in_other: smoothstep(0.60, 0.84, progress), + in_vocals: smoothstep(if strong { 0.86 } else { 0.82 }, 0.96, progress), + }, + } + } + + fn bridge_gate(self, progress: f32, strong: bool) -> f32 { + let start = match self { + Self::EchoDrop => 0.28, + _ => 0.18, + }; + let end = if strong { 0.88 } else { 0.92 }; + smoothstep(start, start + 0.12, progress) * (1.0 - smoothstep(end, 0.98, progress)) + } + + fn percussion_gate(self, progress: f32) -> f32 { + match self { + Self::HarmonicBridge => 0.34 * smoothstep(0.24, 0.44, progress), + Self::EchoDrop => 0.72 * smoothstep(0.32, 0.68, progress), + _ => 0.48 * smoothstep(0.20, 0.44, progress), + } + } + + fn uses_echo_out(self) -> bool { + matches!(self, Self::EchoDrop | Self::CustomBridge) + } + + fn has_drop_cut(self) -> bool { + matches!(self, Self::EchoDrop) + } + + fn diagnostic_name(self) -> &'static str { + match self { + Self::DrumSwap => "drum_swap", + Self::HarmonicBridge => "harmonic_bridge", + Self::EchoDrop => "echo_drop", + Self::StemMashup => "stem_mashup", + Self::CustomBridge => "custom_bridge", + } + } +} + +#[derive(Clone, Copy)] +struct StemGates { + out_drums: f32, + out_bass: f32, + out_other: f32, + out_vocals: f32, + in_drums: f32, + in_bass: f32, + in_other: f32, + in_vocals: f32, +} + +fn mix_stem_transition_frame( + outgoing: &StemSet, + outgoing_index: usize, + incoming: &StemSet, + incoming_index: usize, + gates: StemGates, + outgoing_gain: f32, + incoming_gain: f32, +) -> [f32; 2] { + let mut mixed = [0.0, 0.0]; + add_scaled( + &mut mixed, + outgoing.frame(StemKind::Drums, outgoing_index), + outgoing_gain * gates.out_drums, + ); + add_scaled( + &mut mixed, + outgoing.frame(StemKind::Bass, outgoing_index), + outgoing_gain * gates.out_bass, + ); + add_scaled( + &mut mixed, + outgoing.frame(StemKind::Other, outgoing_index), + outgoing_gain * gates.out_other, + ); + add_scaled( + &mut mixed, + outgoing.frame(StemKind::Vocals, outgoing_index), + outgoing_gain * gates.out_vocals, + ); + add_scaled( + &mut mixed, + incoming.frame(StemKind::Drums, incoming_index), + incoming_gain * gates.in_drums, + ); + add_scaled( + &mut mixed, + incoming.frame(StemKind::Bass, incoming_index), + incoming_gain * gates.in_bass, + ); + add_scaled( + &mut mixed, + incoming.frame(StemKind::Other, incoming_index), + incoming_gain * gates.in_other, + ); + add_scaled( + &mut mixed, + incoming.frame(StemKind::Vocals, incoming_index), + incoming_gain * gates.in_vocals, + ); + mixed +} + +fn add_scaled(target: &mut [f32; 2], frame: [f32; 2], gain: f32) { + target[0] += frame[0] * gain; + target[1] += frame[1] * gain; +} + +#[allow(clippy::too_many_arguments)] +fn transition_deck_bridge( + recipe: TransitionRecipe, + outgoing: &[[f32; 2]], + incoming: &[[f32; 2]], + outgoing_stems: Option<&StemSet>, + outgoing_stem_offset: usize, + incoming_stems: Option<&StemSet>, + index: usize, + frames: usize, + loop_frames: usize, + harmonic_filter: &mut LowPassStereo, +) -> [f32; 2] { + let progress = normalized_progress(index, frames); + let loop_position = index % loop_frames.max(1); + if let (Some(out_stems), Some(in_stems)) = (outgoing_stems, incoming_stems) { + let out_loop_start = outgoing_stem_offset + frames.saturating_sub(loop_frames); + let out_index = out_loop_start + loop_position; + let in_index = loop_position; + let out_drums = out_stems.frame(StemKind::Drums, out_index); + let in_drums = in_stems.frame(StemKind::Drums, in_index); + let out_other = out_stems.frame(StemKind::Other, out_index); + let in_other = in_stems.frame(StemKind::Other, in_index); + return match recipe { + TransitionRecipe::DrumSwap => [ + out_drums[0] * (1.0 - progress) + in_drums[0] * progress, + out_drums[1] * (1.0 - progress) + in_drums[1] * progress, + ], + TransitionRecipe::HarmonicBridge => { + let harmonic = [ + out_other[0] * (1.0 - progress) + in_other[0] * progress, + out_other[1] * (1.0 - progress) + in_other[1] * progress, + ]; + harmonic_filter.process(harmonic) + } + TransitionRecipe::EchoDrop => [out_drums[0] * 0.42, out_drums[1] * 0.42], + TransitionRecipe::StemMashup | TransitionRecipe::CustomBridge => [ + out_other[0] * (1.0 - progress) + in_drums[0] * progress, + out_other[1] * (1.0 - progress) + in_drums[1] * progress, + ], + }; + } + + let out_loop_start = frames.saturating_sub(loop_frames); + let out_index = (out_loop_start + loop_position).min(outgoing.len().saturating_sub(1)); + let in_index = loop_position.min(incoming.len().saturating_sub(1)); + let out = outgoing.get(out_index).copied().unwrap_or([0.0, 0.0]); + let input = incoming.get(in_index).copied().unwrap_or([0.0, 0.0]); + let blend = [ + out[0] * (1.0 - progress) + input[0] * progress, + out[1] * (1.0 - progress) + input[1] * progress, + ]; + harmonic_filter.process(blend) +} + +fn keys_are_compatible(current: &PlannedTrack, next: &PlannedTrack) -> bool { + analysis_keys_are_compatible(¤t.analysis, &next.analysis) +} + +fn analysis_keys_are_compatible(left: &TrackAnalysis, right: &TrackAnalysis) -> bool { + let (Some(left_key), Some(right_key)) = + (left.musical_key.as_deref(), right.musical_key.as_deref()) + else { + return false; + }; + harmonic_keys_are_compatible(left_key, right_key) +} + +fn harmonic_keys_are_compatible(left: &str, right: &str) -> bool { + let (Some((left_pitch, left_mode)), Some((right_pitch, right_mode))) = + (parse_musical_key(left), parse_musical_key(right)) + else { + return left.eq_ignore_ascii_case(right); + }; + if left_pitch == right_pitch && left_mode == right_mode { + return true; + } + + match (left_mode, right_mode) { + (KeyMode::Major, KeyMode::Minor) => (right_pitch + 3).rem_euclid(12) == left_pitch, + (KeyMode::Minor, KeyMode::Major) => (left_pitch + 3).rem_euclid(12) == right_pitch, + _ => { + let interval = (right_pitch - left_pitch).rem_euclid(12); + matches!(interval, 0 | 5 | 7) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyMode { + Major, + Minor, + Unknown, +} + +fn parse_musical_key(key: &str) -> Option<(i32, KeyMode)> { + let mut tokens = key.split_whitespace(); + let pitch = key_pitch_class(tokens.next()?)?; + let mode = match tokens.next().map(str::to_ascii_lowercase).as_deref() { + Some("major" | "maj") => KeyMode::Major, + Some("minor" | "min") => KeyMode::Minor, + _ => KeyMode::Unknown, + }; + Some((pitch, mode)) +} + +fn key_pitch_class(token: &str) -> Option { + match token { + "C" => Some(0), + "C#" | "Db" => Some(1), + "D" => Some(2), + "D#" | "Eb" => Some(3), + "E" => Some(4), + "F" => Some(5), + "F#" | "Gb" => Some(6), + "G" => Some(7), + "G#" | "Ab" => Some(8), + "A" => Some(9), + "A#" | "Bb" => Some(10), + "B" => Some(11), + _ => None, + } +} + +struct CustomBridgeAudio { + frames: Vec<[f32; 2]>, +} + +impl CustomBridgeAudio { + fn render(&self, frame_index: usize, _progress: f32) -> [f32; 2] { + if self.frames.is_empty() { + return [0.0, 0.0]; + } + let frame_count = self.frames.len(); + let position = frame_index % frame_count; + let crossfade_frames = (OUTPUT_SAMPLE_RATE as usize / 50) + .min(frame_count / 4) + .max(1); + let crossfade_start = frame_count.saturating_sub(crossfade_frames); + if position >= crossfade_start { + let head_index = position - crossfade_start; + let blend = smoothstep(0.0, 1.0, head_index as f32 / crossfade_frames as f32); + let tail = self.frames[position]; + let head = self.frames[head_index.min(frame_count - 1)]; + return [ + tail[0] * (1.0 - blend) + head[0] * blend, + tail[1] * (1.0 - blend) + head[1] * blend, + ]; + } + self.frames[position] + } +} + +fn load_custom_bridge( + request: &ExportRequest, + sender: &Sender, + cancel: &AtomicBool, +) -> Result> { + if request.options.bridge_mode != DjBridgeMode::Custom { + return Ok(None); + } + let path = request + .custom_bridge_path + .as_ref() + .ok_or_else(|| anyhow!("Custom bridge mode requires an audio file."))?; + send_progress(sender, "Preparing custom bridge audio".to_owned(), 0.39); + let start = request.options.bridge_start_seconds.max(0.0); + let end = start + request.options.bridge_loop_seconds.clamp(0.25, 60.0); + let frames = decode_section(path, start, end, cancel) + .with_context(|| format!("Failed to prepare custom bridge: {}", path.display()))?; + Ok(Some(CustomBridgeAudio { frames })) +} + +struct TransitionPercussion { + bpm: f32, + seed: u32, + kick_phase: f32, + noise_state: u32, +} + +impl TransitionPercussion { + fn new(bpm: f32, seed: u32) -> Self { + Self { + bpm, + seed, + kick_phase: 0.0, + noise_state: seed ^ 0xA5A5_17C3, + } + } + + fn render(&mut self, frame_index: usize, progress: f32, strong: bool) -> [f32; 2] { + let seconds = frame_index as f32 / OUTPUT_SAMPLE_RATE as f32; + let beats = seconds * self.bpm / 60.0; + let beat_phase = beats.fract(); + let half_beat_phase = (beats * 2.0).fract(); + let kick_env = (-beat_phase * 19.0).exp(); + let kick_hz = 46.0 + 76.0 * (-beat_phase * 25.0).exp(); + self.kick_phase = (self.kick_phase + kick_hz / OUTPUT_SAMPLE_RATE as f32).fract(); + let kick = (std::f32::consts::TAU * self.kick_phase).sin() * kick_env * 0.20; + let noise = transition_noise(&mut self.noise_state); + let hat = if half_beat_phase < 0.08 { + noise * (1.0 - half_beat_phase / 0.08) * 0.042 + } else { + 0.0 + }; + let snare_phase = (beats + 0.5).fract(); + let snare = if snare_phase < 0.10 { + noise * (1.0 - snare_phase / 0.10).powi(2) * if strong { 0.09 } else { 0.055 } + } else { + 0.0 + }; + let roll = smoothstep(0.56, 0.78, progress); + let pan = (((self.seed & 0xff) as f32 / 255.0) - 0.5) * 0.18; + [ + kick + hat * (1.0 - pan) + snare * roll, + kick + hat * (1.0 + pan) + snare * roll, + ] + } +} + +#[derive(Clone, Copy, Debug)] +struct VocalHandoffPlan { + outgoing_activity: f32, + incoming_activity: f32, + handoff_progress: f32, +} + +impl VocalHandoffPlan { + fn duck_amounts(self, _index: usize, progress: f32) -> (f32, f32) { + let both_vocal = self.outgoing_activity.min(self.incoming_activity); + if both_vocal < 0.18 { + return (0.0, 0.0); + } + + let handoff_width = 0.08; + let handoff = ((progress - (self.handoff_progress - handoff_width)) + / (handoff_width * 2.0)) + .clamp(0.0, 1.0); + let strength = ((both_vocal - 0.18) / 0.42).clamp(0.0, 1.0); + let max_duck = 0.88 * strength; + + // One lead vocal at a time: incoming stays back before the phrase handoff, + // outgoing gets removed after it. The short interpolation avoids a hard hole. + (max_duck * handoff, max_duck * (1.0 - handoff)) + } +} + +fn plan_vocal_handoff(outgoing: &[[f32; 2]], incoming: &[[f32; 2]]) -> VocalHandoffPlan { + let outgoing_activity = estimate_center_vocal_activity(outgoing); + let incoming_activity = estimate_center_vocal_activity(incoming); + let bias = (incoming_activity - outgoing_activity) * 0.08; + VocalHandoffPlan { + outgoing_activity, + incoming_activity, + handoff_progress: (0.52 - bias).clamp(0.42, 0.62), + } +} + +fn estimate_center_vocal_activity(frames: &[[f32; 2]]) -> f32 { + if frames.is_empty() { + return 0.0; + } + let stride = (OUTPUT_SAMPLE_RATE as usize / 50).max(1); + let mut active_blocks = 0usize; + let mut measured_blocks = 0usize; + for block in frames.chunks(stride) { + let mut mid_energy = 0.0f32; + let mut side_energy = 0.0f32; + let mut total_energy = 0.0f32; + for frame in block { + let mid = (frame[0] + frame[1]) * 0.5; + let side = (frame[0] - frame[1]) * 0.5; + mid_energy += mid * mid; + side_energy += side * side; + total_energy += (frame[0] * frame[0] + frame[1] * frame[1]) * 0.5; + } + let count = block.len().max(1) as f32; + let rms = (total_energy / count).sqrt(); + if rms < 0.012 { + continue; + } + measured_blocks += 1; + let center_ratio = mid_energy / (mid_energy + side_energy + 1.0e-9); + if center_ratio > 0.68 { + active_blocks += 1; + } + } + if measured_blocks == 0 { + 0.0 + } else { + active_blocks as f32 / measured_blocks as f32 + } +} + +struct CenterVocalGuard { + bass_low_pass: f32, +} + +impl CenterVocalGuard { + fn new() -> Self { + Self { bass_low_pass: 0.0 } + } + + fn process(&mut self, frame: [f32; 2], amount: f32) -> [f32; 2] { + let amount = amount.clamp(0.0, 0.92); + if amount <= 0.0001 { + return frame; + } + let mid = (frame[0] + frame[1]) * 0.5; + let side = (frame[0] - frame[1]) * 0.5; + // Preserve centered kick/bass while suppressing the vocal-heavy center band. + let alpha = 1.0 - (-std::f32::consts::TAU * 180.0 / OUTPUT_SAMPLE_RATE as f32).exp(); + self.bass_low_pass += alpha * (mid - self.bass_low_pass); + let upper_center = mid - self.bass_low_pass; + let guarded_mid = self.bass_low_pass + upper_center * (1.0 - amount); + [guarded_mid + side, guarded_mid - side] + } +} + +fn transition_should_hit_hard(current: &PlannedTrack, next: &PlannedTrack) -> bool { + let current_energy = current + .analysis + .energy_curve + .iter() + .copied() + .fold(0.0, f32::max); + let next_energy = next + .analysis + .energy_curve + .iter() + .copied() + .fold(0.0, f32::max); + let energy_jump = (next_energy - current_energy).abs(); + let bpm_jump = (next.analysis.bpm - current.analysis.bpm).abs(); + energy_jump > 0.22 || bpm_jump > 12.0 +} + +fn transition_seed(current: &PlannedTrack, next: &PlannedTrack) -> u32 { + current + .track + .title + .bytes() + .chain(next.track.title.bytes()) + .fold(0x9E37_79B9, |state, byte| { + state.rotate_left(5) ^ byte as u32 + }) +} + +fn transition_noise(state: &mut u32) -> f32 { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + (*state as f32 / u32::MAX as f32) * 2.0 - 1.0 +} + +fn mix_frame(dry: [f32; 2], wet: [f32; 2], amount: f32) -> [f32; 2] { + let amount = amount.clamp(0.0, 1.0); + [ + dry[0] * (1.0 - amount) + wet[0] * amount, + dry[1] * (1.0 - amount) + wet[1] * amount, + ] +} + +struct StereoDelay { + buffer: Vec<[f32; 2]>, + cursor: usize, + feedback: f32, +} + +impl StereoDelay { + fn new(frames: usize, feedback: f32) -> Self { + Self { + buffer: vec![[0.0, 0.0]; frames.max(1)], + cursor: 0, + feedback: feedback.clamp(0.0, 0.92), + } + } + + fn process(&mut self, input: [f32; 2]) -> [f32; 2] { + let delayed = self.buffer[self.cursor]; + self.buffer[self.cursor] = [ + input[0] + delayed[0] * self.feedback, + input[1] + delayed[1] * self.feedback, + ]; + self.cursor = (self.cursor + 1) % self.buffer.len(); + delayed + } +} + +fn apply_bass_swap( + outgoing: &mut [f32; 2], + incoming: &mut [f32; 2], + progress: f32, + outgoing_filter: &mut LowPassStereo, + incoming_filter: &mut LowPassStereo, +) { + let outgoing_low = outgoing_filter.process(*outgoing); + let incoming_low = incoming_filter.process(*incoming); + let outgoing_low_gain = if progress < 0.50 { 1.0 } else { 0.0 }; + let incoming_low_gain = if progress < 0.50 { 0.0 } else { 1.0 }; + *outgoing = [ + outgoing[0] - outgoing_low[0] + outgoing_low[0] * outgoing_low_gain, + outgoing[1] - outgoing_low[1] + outgoing_low[1] * outgoing_low_gain, + ]; + *incoming = [ + incoming[0] - incoming_low[0] + incoming_low[0] * incoming_low_gain, + incoming[1] - incoming_low[1] + incoming_low[1] * incoming_low_gain, + ]; +} + +struct LowPassStereo { + alpha: f32, + state: [f32; 2], +} + +impl LowPassStereo { + fn new(cutoff_hz: f32) -> Self { + let mut filter = Self { + alpha: 0.0, + state: [0.0, 0.0], + }; + filter.set_cutoff(cutoff_hz); + filter + } + + fn process(&mut self, frame: [f32; 2]) -> [f32; 2] { + self.state[0] += self.alpha * (frame[0] - self.state[0]); + self.state[1] += self.alpha * (frame[1] - self.state[1]); + self.state + } + + fn set_cutoff(&mut self, cutoff_hz: f32) { + let cutoff_hz = cutoff_hz.clamp(20.0, OUTPUT_SAMPLE_RATE as f32 * 0.45); + let dt = 1.0 / OUTPUT_SAMPLE_RATE as f32; + let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff_hz); + self.alpha = dt / (rc + dt); + } +} + +fn cached_or_analyze( + track: &DjMixTrack, + cache: &mut AnalysisCache, + tools: &ProfessionalToolchain, + cancel: &AtomicBool, +) -> Result { + let metadata = fs::metadata(&track.path) + .with_context(|| format!("Track is missing: {}", track.path.display()))?; + let modified_nanos = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_nanos()) + .unwrap_or(0); + let analyzer_version = if tools.essentia.is_some() { + format!("{ANALYZER_VERSION}+essentia") + } else { + format!("{ANALYZER_VERSION}+builtin") + }; + let key = track.path.to_string_lossy().to_string(); + if cache.schema_version == ENGINE_SCHEMA_VERSION { + if let Some(entry) = cache.tracks.get(&key) { + if entry.file_len == metadata.len() + && entry.modified_nanos == modified_nanos + && entry.analyzer_version == analyzer_version + { + return Ok(entry.analysis.clone()); + } + } + } + + let analysis = analyze_track(&track.path, tools, cancel)?; + cache.schema_version = ENGINE_SCHEMA_VERSION; + cache.tracks.insert( + key, + CachedTrackAnalysis { + file_len: metadata.len(), + modified_nanos, + analyzer_version, + analysis: analysis.clone(), + }, + ); + Ok(analysis) +} + +fn analyze_track( + path: &Path, + tools: &ProfessionalToolchain, + cancel: &AtomicBool, +) -> Result { + let file = File::open(path) + .with_context(|| format!("Failed to open audio file: {}", path.display()))?; + let decoder = Decoder::new(BufReader::new(file)) + .with_context(|| format!("Failed to decode audio file: {}", path.display()))?; + let declared_duration = decoder.total_duration().map(|value| value.as_secs_f32()); + let mut source = + UniformSourceIterator::<_, f32>::new(decoder, OUTPUT_CHANNELS, OUTPUT_SAMPLE_RATE); + let mut loudness = EbuR128::new( + OUTPUT_CHANNELS as u32, + OUTPUT_SAMPLE_RATE, + Mode::I | Mode::SAMPLE_PEAK | Mode::TRUE_PEAK, + ) + .map_err(|error| anyhow!("Failed to initialize EBU R128 analyzer: {error}"))?; + + let analysis_frames = OUTPUT_SAMPLE_RATE as usize * MAX_ANALYSIS_SECONDS; + let envelope_stride = OUTPUT_SAMPLE_RATE as usize / ANALYSIS_RATE_HZ; + let energy_stride = OUTPUT_SAMPLE_RATE as usize / ENERGY_RATE_HZ; + let meter_chunk_frames = 4096usize; + let mut meter_chunk = Vec::with_capacity(meter_chunk_frames * 2); + let mut envelope = Vec::new(); + let mut energy_curve = Vec::new(); + let mut envelope_sum = 0.0f32; + let mut envelope_count = 0usize; + let mut energy_sum = 0.0f32; + let mut energy_count = 0usize; + let mut frames_read = 0usize; + + while frames_read < analysis_frames { + if frames_read % (OUTPUT_SAMPLE_RATE as usize / 2) == 0 { + ensure_not_cancelled(cancel)?; + } + let Some(frame) = read_stereo_frame(&mut source) else { + break; + }; + frames_read += 1; + meter_chunk.extend_from_slice(&frame); + let mono = (frame[0] + frame[1]) * 0.5; + envelope_sum += mono.abs(); + envelope_count += 1; + energy_sum += mono * mono; + energy_count += 1; + + if envelope_count >= envelope_stride { + envelope.push(envelope_sum / envelope_count as f32); + envelope_sum = 0.0; + envelope_count = 0; + } + if energy_count >= energy_stride { + energy_curve.push((energy_sum / energy_count as f32).sqrt()); + energy_sum = 0.0; + energy_count = 0; + } + if meter_chunk.len() >= meter_chunk_frames * 2 { + loudness + .add_frames_f32(&meter_chunk) + .map_err(|error| anyhow!("EBU R128 analysis failed: {error}"))?; + meter_chunk.clear(); + } + } + if !meter_chunk.is_empty() { + loudness + .add_frames_f32(&meter_chunk) + .map_err(|error| anyhow!("EBU R128 analysis failed: {error}"))?; + } + if envelope_count > 0 { + envelope.push(envelope_sum / envelope_count as f32); + } + if energy_count > 0 { + energy_curve.push((energy_sum / energy_count as f32).sqrt()); + } + if frames_read == 0 { + return Err(anyhow!( + "Track contains no decodable audio: {}", + path.display() + )); + } + + let decoded_duration = frames_read as f32 / OUTPUT_SAMPLE_RATE as f32; + let duration_seconds = declared_duration + .unwrap_or(decoded_duration) + .max(decoded_duration); + let silence_threshold = silence_threshold(&envelope); + let leading_index = + first_sustained_level(&envelope, silence_threshold, ANALYSIS_RATE_HZ / 5).unwrap_or(0); + let trailing_index = last_sustained_level(&envelope, silence_threshold, ANALYSIS_RATE_HZ / 5) + .unwrap_or_else(|| envelope.len().saturating_sub(1)); + let leading_silence_seconds = leading_index as f32 / ANALYSIS_RATE_HZ as f32; + let trailing_silence_seconds = if decoded_duration >= duration_seconds - 0.25 { + ((envelope.len().saturating_sub(1 + trailing_index)) as f32 / ANALYSIS_RATE_HZ as f32) + .min(duration_seconds) + } else { + 0.0 + }; + + let mut onset = Vec::with_capacity(envelope.len()); + let mut previous = envelope.first().copied().unwrap_or(0.0); + for value in &envelope { + onset.push((value - previous).max(0.0)); + previous = *value; + } + remove_local_mean(&mut onset, ANALYSIS_RATE_HZ / 2); + let (bpm, beat_lag, bpm_confidence) = estimate_bpm(&onset); + let beat_phase = estimate_beat_phase(&onset, beat_lag); + let downbeat_phase = estimate_downbeat_phase(&onset, beat_phase, beat_lag); + let beat_offset_seconds = beat_phase as f32 / ANALYSIS_RATE_HZ as f32; + let downbeat_offset_seconds = downbeat_phase as f32 / ANALYSIS_RATE_HZ as f32; + let sections = detect_sections( + &energy_curve, + duration_seconds, + leading_silence_seconds, + trailing_silence_seconds, + ); + + let mut beat_positions_seconds = Vec::new(); + let beat_period = 60.0 / bpm.max(1.0); + let mut beat = beat_offset_seconds.max(0.0); + while beat <= duration_seconds { + beat_positions_seconds.push(beat); + beat += beat_period; + } + + let mut analysis = TrackAnalysis { + bpm, + bpm_confidence, + beat_offset_seconds, + downbeat_offset_seconds, + leading_silence_seconds, + trailing_silence_seconds, + integrated_lufs: loudness + .loudness_global() + .ok() + .filter(|value| value.is_finite()), + sample_peak: maximum_peak(&loudness, false).unwrap_or(0.0), + true_peak: maximum_peak(&loudness, true).unwrap_or(0.0), + duration_seconds, + energy_curve, + beat_positions_seconds, + sections, + musical_key: None, + key_confidence: None, + vocal_profile: AnalysisAvailability::Unavailable { + reason: "Stem separation is evaluated during export when Demucs is available." + .to_owned(), + }, + analysis_backend: "built-in rhythm envelope + ebur128".to_owned(), + }; + + if let Some(essentia) = tools.essentia.as_ref() { + match analyze_with_essentia(path, essentia, cancel) { + Ok(external) => apply_essentia_analysis(&mut analysis, external), + Err(error) if cancel.load(AtomicOrdering::Relaxed) => return Err(error), + Err(_) => {} + } + } + Ok(analysis) +} + +#[derive(Clone, Debug)] +struct EssentiaTrackAnalysis { + bpm: Option, + confidence: Option, + beats: Vec, + key: Option, + key_strength: Option, +} + +fn analyze_with_essentia( + path: &Path, + essentia: &ExternalCommand, + cancel: &AtomicBool, +) -> Result { + let cache_dir = professional_cache_dir().join("essentia"); + fs::create_dir_all(&cache_dir)?; + let cache_key = file_cache_key(path, &[ANALYZER_VERSION.as_bytes()])?; + let output_path = cache_dir.join(format!("{cache_key:016x}.json")); + let profile_path = cache_dir.join("audio-orbit-music-extractor.yaml"); + if !profile_path.is_file() { + fs::write( + &profile_path, + concat!( + "outputFormat: json\n", + "outputFrames: 0\n", + "requireMbid: false\n", + "indent: 2\n", + "analysisSampleRate: 44100.0\n", + "rhythm:\n", + " method: multifeature\n", + " minTempo: 70\n", + " maxTempo: 180\n", + "tonal:\n", + " frameSize: 4096\n", + " hopSize: 2048\n", + " zeroPadding: 0\n", + " windowType: blackmanharris62\n", + " silentFrames: noise\n", + "highlevel:\n", + " compute: 0\n", + ), + )?; + } + + if !output_path.is_file() { + let mut command = essentia.command(); + command + .arg(path) + .arg(&output_path) + .arg(&profile_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = run_command_cancellable(command, cancel) + .context("Essentia music analysis failed to start")?; + if !status.success() { + return Err(anyhow!("Essentia exited with {status}")); + } + } + + let value: serde_json::Value = + serde_json::from_slice(&fs::read(&output_path).with_context(|| { + format!("Failed to read Essentia output: {}", output_path.display()) + })?) + .context("Failed to parse Essentia JSON output")?; + let bpm = json_number(&value, &["rhythm.bpm"]); + let confidence = json_number(&value, &["rhythm.confidence", "rhythm.beats_confidence"]); + let beats = json_number_array(&value, &["rhythm.beats_position", "rhythm.beats_positions"]); + let key_name = json_string( + &value, + &[ + "tonal.key_edma.key", + "tonal.key_temperley.key", + "tonal.key_krumhansl.key", + ], + ); + let scale = json_string( + &value, + &[ + "tonal.key_edma.scale", + "tonal.key_temperley.scale", + "tonal.key_krumhansl.scale", + ], + ); + let key_strength = json_number( + &value, + &[ + "tonal.key_edma.strength", + "tonal.key_temperley.strength", + "tonal.key_krumhansl.strength", + ], + ); + let key = match (key_name, scale) { + (Some(key), Some(scale)) => Some(format!("{key} {scale}")), + (Some(key), None) => Some(key), + _ => None, + }; + Ok(EssentiaTrackAnalysis { + bpm, + confidence, + beats, + key, + key_strength, + }) +} + +fn apply_essentia_analysis(analysis: &mut TrackAnalysis, external: EssentiaTrackAnalysis) { + if let Some(bpm) = external + .bpm + .filter(|bpm| bpm.is_finite() && (MIN_BPM..=MAX_BPM).contains(bpm)) + { + analysis.bpm = bpm; + } + if !external.beats.is_empty() { + analysis.beat_positions_seconds = external + .beats + .into_iter() + .filter(|beat| beat.is_finite() && *beat >= 0.0 && *beat <= analysis.duration_seconds) + .collect(); + if let Some(first) = analysis.beat_positions_seconds.first().copied() { + analysis.beat_offset_seconds = first; + analysis.downbeat_offset_seconds = estimate_downbeat_from_beats( + &analysis.beat_positions_seconds, + &analysis.energy_curve, + ); + } + } + analysis.bpm_confidence = external + .confidence + .filter(|value| value.is_finite()) + .unwrap_or_else(|| beat_grid_confidence(&analysis.beat_positions_seconds)) + .clamp(0.0, 1.0); + analysis.musical_key = external.key; + analysis.key_confidence = external.key_strength.map(|value| value.clamp(0.0, 1.0)); + analysis.analysis_backend = "Essentia music extractor + ebur128".to_owned(); +} + +fn estimate_downbeat_from_beats(beats: &[f32], energy_curve: &[f32]) -> f32 { + if beats.is_empty() { + return 0.0; + } + let mut best_phase = 0usize; + let mut best_score = f32::NEG_INFINITY; + for phase in 0..4 { + let mut score = 0.0f32; + let mut count = 0usize; + for beat in beats.iter().skip(phase).step_by(4) { + let index = seconds_to_energy_index(*beat).min(energy_curve.len().saturating_sub(1)); + if let Some(value) = energy_curve.get(index) { + score += *value; + count += 1; + } + } + if count > 0 { + score /= count as f32; + } + if score > best_score { + best_score = score; + best_phase = phase; + } + } + beats.get(best_phase).copied().unwrap_or(beats[0]) +} + +fn beat_grid_confidence(beats: &[f32]) -> f32 { + if beats.len() < 4 { + return 0.0; + } + let intervals = beats + .windows(2) + .map(|pair| pair[1] - pair[0]) + .collect::>(); + let mean = intervals.iter().sum::() / intervals.len() as f32; + if mean <= 0.0 { + return 0.0; + } + let variance = intervals + .iter() + .map(|value| (value - mean).powi(2)) + .sum::() + / intervals.len() as f32; + (1.0 - variance.sqrt() / mean).clamp(0.0, 1.0) +} + +fn json_value<'a>(root: &'a serde_json::Value, dotted_path: &str) -> Option<&'a serde_json::Value> { + if let Some(value) = root.get(dotted_path) { + return Some(value); + } + let mut current = root; + for part in dotted_path.split('.') { + current = current.get(part)?; + } + Some(current) +} + +fn json_number(root: &serde_json::Value, paths: &[&str]) -> Option { + paths.iter().find_map(|path| { + let value = json_value(root, path)?; + value.as_f64().map(|number| number as f32).or_else(|| { + value + .as_array()? + .first()? + .as_f64() + .map(|number| number as f32) + }) + }) +} + +fn json_string(root: &serde_json::Value, paths: &[&str]) -> Option { + paths + .iter() + .find_map(|path| json_value(root, path)?.as_str().map(ToOwned::to_owned)) +} + +fn json_number_array(root: &serde_json::Value, paths: &[&str]) -> Vec { + paths + .iter() + .find_map(|path| { + json_value(root, path)?.as_array().map(|values| { + values + .iter() + .filter_map(|value| value.as_f64().map(|number| number as f32)) + .collect::>() + }) + }) + .unwrap_or_default() +} + +fn run_command_cancellable(mut command: Command, cancel: &AtomicBool) -> Result { + let mut child = command.spawn()?; + wait_for_child(&mut child, cancel) +} + +fn wait_for_child(child: &mut Child, cancel: &AtomicBool) -> Result { + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if cancel.load(AtomicOrdering::Relaxed) { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!("DJ mix export cancelled.")); + } + thread::sleep(Duration::from_millis(50)); + } +} + +fn professional_cache_dir() -> PathBuf { + app_data_dir() + .unwrap_or_else(env::temp_dir) + .join("dj-professional-cache") +} + +fn file_cache_key(path: &Path, extra: &[&[u8]]) -> Result { + let metadata = fs::metadata(path)?; + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_nanos()) + .unwrap_or(0); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + path.to_string_lossy().hash(&mut hasher); + metadata.len().hash(&mut hasher); + modified.hash(&mut hasher); + for value in extra { + value.hash(&mut hasher); + } + Ok(hasher.finish()) +} + +fn silence_threshold(envelope: &[f32]) -> f32 { + if envelope.is_empty() { + return 0.0005; + } + let mut sorted = envelope.to_vec(); + sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal)); + let floor = percentile(&sorted, 0.10); + let body = percentile(&sorted, 0.75); + (floor * 2.5).max(body * 0.04).max(0.0005) +} + +fn estimate_bpm(onset: &[f32]) -> (f32, usize, f32) { + if onset.len() < ANALYSIS_RATE_HZ * 8 { + let lag = (ANALYSIS_RATE_HZ as f32 * 60.0 / DEFAULT_BPM).round() as usize; + return (DEFAULT_BPM, lag, 0.0); + } + let min_lag = (ANALYSIS_RATE_HZ as f32 * 60.0 / MAX_BPM).floor() as usize; + let max_lag = (ANALYSIS_RATE_HZ as f32 * 60.0 / MIN_BPM).ceil() as usize; + let mut best_lag = min_lag.max(1); + let mut best_score = 0.0f32; + let mut second_score = 0.0f32; + for lag in min_lag.max(1)..=max_lag.min(onset.len().saturating_sub(1)) { + let mut score = 0.0f32; + for index in lag..onset.len() { + score += onset[index] * onset[index - lag]; + } + score /= (onset.len() - lag) as f32; + if score > best_score { + second_score = best_score; + best_score = score; + best_lag = lag; + } else if score > second_score { + second_score = score; + } + } + let bpm = (ANALYSIS_RATE_HZ as f32 * 60.0 / best_lag as f32).clamp(MIN_BPM, MAX_BPM); + let confidence = if best_score > 0.0 { + ((best_score - second_score) / best_score).clamp(0.0, 1.0) + } else { + 0.0 + }; + (bpm, best_lag, confidence) +} + +fn estimate_beat_phase(onset: &[f32], lag: usize) -> usize { + if onset.is_empty() || lag == 0 { + return 0; + } + (0..lag.min(onset.len())) + .max_by(|left, right| { + let score = |phase: usize| onset.iter().skip(phase).step_by(lag).copied().sum::(); + score(*left) + .partial_cmp(&score(*right)) + .unwrap_or(Ordering::Equal) + }) + .unwrap_or(0) +} + +fn estimate_downbeat_phase(onset: &[f32], beat_phase: usize, beat_lag: usize) -> usize { + if onset.is_empty() || beat_lag == 0 { + return beat_phase; + } + let bar_lag = beat_lag.saturating_mul(4); + (0..4) + .map(|beat_in_bar| beat_phase + beat_in_bar * beat_lag) + .filter(|phase| *phase < onset.len()) + .max_by(|left, right| { + let score = |phase: usize| { + onset + .iter() + .skip(phase) + .step_by(bar_lag.max(1)) + .copied() + .sum::() + }; + score(*left) + .partial_cmp(&score(*right)) + .unwrap_or(Ordering::Equal) + }) + .unwrap_or(beat_phase) +} + +fn detect_sections( + energy_curve: &[f32], + duration_seconds: f32, + leading_silence_seconds: f32, + trailing_silence_seconds: f32, +) -> Vec { + let playable_start = leading_silence_seconds.min(duration_seconds); + let playable_end = (duration_seconds - trailing_silence_seconds) + .max(playable_start) + .min(duration_seconds); + if playable_end - playable_start < 8.0 || energy_curve.is_empty() { + return vec![DetectedSection { + kind: SectionKind::Main, + start_seconds: playable_start, + end_seconds: playable_end, + energy: 0.0, + confidence: 0.25, + }]; + } + + let mut sorted = energy_curve.to_vec(); + sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal)); + let low = percentile(&sorted, 0.30); + let high = percentile(&sorted, 0.75); + let intro_end = (playable_start + 24.0).min(playable_end); + let outro_start = (playable_end - 24.0).max(playable_start); + let body_start = intro_end; + let body_end = outro_start.max(body_start); + let body_start_index = seconds_to_energy_index(body_start).min(energy_curve.len()); + let body_end_index = seconds_to_energy_index(body_end).min(energy_curve.len()); + let body = &energy_curve[body_start_index..body_end_index.max(body_start_index)]; + let peak_index = body + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| left.partial_cmp(right).unwrap_or(Ordering::Equal)) + .map(|(index, _)| body_start_index + index) + .unwrap_or(body_start_index); + let peak_seconds = peak_index as f32 / ENERGY_RATE_HZ as f32; + let drop_start = (peak_seconds - 8.0).clamp(body_start, body_end); + let drop_end = (drop_start + 32.0).min(body_end); + let breakdown_start = (drop_start - 32.0).max(body_start); + let build_start = (drop_start - 16.0).max(breakdown_start); + let chorus_start = (drop_end + 8.0).min(body_end); + let chorus_end = (chorus_start + 32.0).min(body_end); + + vec![ + DetectedSection { + kind: SectionKind::Intro, + start_seconds: playable_start, + end_seconds: intro_end, + energy: average_energy_range( + energy_curve, + seconds_to_energy_index(playable_start), + seconds_to_energy_index(intro_end), + ), + confidence: 0.55, + }, + DetectedSection { + kind: SectionKind::Breakdown, + start_seconds: breakdown_start, + end_seconds: build_start, + energy: low, + confidence: 0.40, + }, + DetectedSection { + kind: SectionKind::BuildUp, + start_seconds: build_start, + end_seconds: drop_start, + energy: average_energy_range( + energy_curve, + seconds_to_energy_index(build_start), + seconds_to_energy_index(drop_start), + ), + confidence: 0.45, + }, + DetectedSection { + kind: SectionKind::Drop, + start_seconds: drop_start, + end_seconds: drop_end, + energy: high, + confidence: 0.65, + }, + DetectedSection { + kind: SectionKind::Chorus, + start_seconds: chorus_start, + end_seconds: chorus_end, + energy: average_energy_range( + energy_curve, + seconds_to_energy_index(chorus_start), + seconds_to_energy_index(chorus_end), + ), + confidence: 0.45, + }, + DetectedSection { + kind: SectionKind::Main, + start_seconds: body_start, + end_seconds: body_end, + energy: average_energy_range(energy_curve, body_start_index, body_end_index), + confidence: 0.60, + }, + DetectedSection { + kind: SectionKind::Outro, + start_seconds: outro_start, + end_seconds: playable_end, + energy: average_energy_range( + energy_curve, + seconds_to_energy_index(outro_start), + energy_curve.len(), + ), + confidence: 0.55, + }, + ] +} + +fn plan_tracks( + mut analyzed: Vec<(DjMixTrack, TrackAnalysis)>, + options: DjMixOptions, +) -> Result> { + if options.smart_order && analyzed.len() > 2 { + analyzed = smart_order(analyzed); + } + let requested_total = options.target_minutes * 60.0; + let transition_seconds = estimated_transition_seconds(&analyzed, options.transition_bars); + let per_track_target = ((requested_total + transition_seconds * (analyzed.len() - 1) as f32) + / analyzed.len() as f32) + .clamp(MIN_SECTION_SECONDS, MAX_AUTO_SECTION_SECONDS); + let mut previous_effective_bpm = analyzed + .first() + .map(|(_, analysis)| analysis.bpm) + .filter(|bpm| bpm.is_finite() && *bpm > 0.0) + .unwrap_or(DEFAULT_BPM); + let mut planned = Vec::with_capacity(analyzed.len()); + + for (index, (track, analysis)) in analyzed.into_iter().enumerate() { + let speed_ratio = match options.style { + DjMixStyle::Crossfade => 1.0, + DjMixStyle::SmartDj if index == 0 || analysis.bpm <= 0.0 => 1.0, + DjMixStyle::SmartDj => (previous_effective_bpm / analysis.bpm) + .clamp(1.0 - MAX_SMART_TEMPO_CHANGE, 1.0 + MAX_SMART_TEMPO_CHANGE), + }; + previous_effective_bpm = (analysis.bpm * speed_ratio).clamp(MIN_BPM, MAX_BPM); + let (section_start_seconds, section_end_seconds, section_reason) = + select_track_section(&track, &analysis, per_track_target, options.transition_bars)?; + let gain = planned_track_gain(&analysis, options.normalize_loudness); + planned.push(PlannedTrack { + track, + analysis, + speed_ratio, + gain, + section_start_seconds, + section_end_seconds, + section_reason, + }); + } + Ok(planned) +} + +fn select_track_section( + track: &DjMixTrack, + analysis: &TrackAnalysis, + target_seconds: f32, + phrase_bars: u32, +) -> Result<(f32, f32, String)> { + let playable_start = analysis + .leading_silence_seconds + .min(analysis.duration_seconds); + let playable_end = (analysis.duration_seconds - analysis.trailing_silence_seconds) + .max(playable_start + 1.0) + .min(analysis.duration_seconds); + let phrase = phrase_seconds(analysis.bpm, phrase_bars); + match track.section_mode { + DjTrackSectionMode::FullTrack => Ok(( + playable_start, + playable_end, + "Full track selected; leading and trailing silence trimmed.".to_owned(), + )), + DjTrackSectionMode::FavoriteRange => { + let start = track.favorite_start_seconds.max(playable_start); + let end = track.favorite_end_seconds.min(playable_end); + if end <= start || end - start < 0.25 { + return Err(anyhow!( + "Favorite range for '{}' must contain at least 0.25 seconds inside track duration.", + track.title + )); + } + let aligned_start = + align_to_phrase(start, analysis.downbeat_offset_seconds, phrase, true); + let aligned_end = align_to_phrase(end, analysis.downbeat_offset_seconds, phrase, false) + .max(aligned_start + phrase.min(end - aligned_start)); + let safe_start = aligned_start + .min((playable_end - 1.0).max(playable_start)) + .max(playable_start); + let safe_end = aligned_end.max(safe_start + 1.0).min(playable_end); + Ok(( + safe_start, + safe_end, + "User favorite range, snapped to phrase boundaries.".to_owned(), + )) + } + DjTrackSectionMode::AutoHighlight => { + let playable_length = playable_end - playable_start; + let desired = target_seconds + .min(playable_length) + .max(MIN_SECTION_SECONDS.min(playable_length)); + let anchor = analysis + .sections + .iter() + .filter(|section| { + matches!( + section.kind, + SectionKind::Drop | SectionKind::Chorus | SectionKind::Main + ) + }) + .max_by(|left, right| { + (left.energy * left.confidence) + .partial_cmp(&(right.energy * right.confidence)) + .unwrap_or(Ordering::Equal) + }); + let center = anchor + .map(|section| (section.start_seconds + section.end_seconds) * 0.5) + .unwrap_or((playable_start + playable_end) * 0.5); + let raw_start = (center - desired * 0.42) + .clamp(playable_start, (playable_end - desired).max(playable_start)); + let aligned_start = + align_to_phrase(raw_start, analysis.downbeat_offset_seconds, phrase, true); + let safe_start = aligned_start + .min((playable_end - 1.0).max(playable_start)) + .max(playable_start); + let raw_end = (safe_start + desired).min(playable_end); + let aligned_end = + align_to_phrase(raw_end, analysis.downbeat_offset_seconds, phrase, false); + let safe_end = aligned_end.max(safe_start + 1.0).min(playable_end); + let label = anchor + .map(|section| { + format!( + "Auto highlight around {:?}; deterministic energy heuristic and phrase alignment.", + section.kind + ) + }) + .unwrap_or_else(|| { + "Auto highlight around track midpoint; phrase-aligned.".to_owned() + }); + Ok((safe_start, safe_end, label)) + } + } +} + +fn planned_track_gain(analysis: &TrackAnalysis, normalize: bool) -> f32 { + let loudness_gain = if normalize { + analysis + .integrated_lufs + .filter(|value| value.is_finite()) + .map(|value| db_to_gain((TARGET_LUFS - value).clamp(-8.0, 8.0) as f32)) + .unwrap_or(1.0) + } else { + 1.0 + }; + let peak = analysis.true_peak.max(analysis.sample_peak) as f32; + let peak_gain = if peak > 0.0 { + (OUTPUT_PEAK_LIMIT / peak).min(1.0) + } else { + 1.0 + }; + loudness_gain.min(peak_gain).clamp(0.25, 2.0) +} + +fn smart_order(mut tracks: Vec<(DjMixTrack, TrackAnalysis)>) -> Vec<(DjMixTrack, TrackAnalysis)> { + if tracks.len() <= 2 { + return tracks; + } + let mut ordered = Vec::with_capacity(tracks.len()); + ordered.push(tracks.remove(0)); + while !tracks.is_empty() { + let previous = &ordered + .last() + .expect("ordered DJ plan always contains the first track") + .1; + let next_index = tracks + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + compatibility_cost(previous, &left.1) + .partial_cmp(&compatibility_cost(previous, &right.1)) + .unwrap_or(Ordering::Equal) + }) + .map(|(index, _)| index) + .unwrap_or(0); + ordered.push(tracks.remove(next_index)); + } + ordered +} + +fn compatibility_cost(previous: &TrackAnalysis, candidate: &TrackAnalysis) -> f32 { + let bpm_cost = (candidate.bpm - previous.bpm).abs(); + let confidence_penalty = (1.0 - candidate.bpm_confidence) * 8.0; + let harmonic_penalty = match ( + previous.musical_key.as_deref(), + candidate.musical_key.as_deref(), + ) { + (Some(_), Some(_)) if analysis_keys_are_compatible(previous, candidate) => 0.0, + (Some(_), Some(_)) => 9.0, + _ => 3.0, + }; + let previous_energy = representative_energy(previous); + let candidate_energy = representative_energy(candidate); + let energy_penalty = (candidate_energy - previous_energy).abs() * 12.0; + bpm_cost + confidence_penalty + harmonic_penalty + energy_penalty +} + +fn representative_energy(analysis: &TrackAnalysis) -> f32 { + if analysis.energy_curve.is_empty() { + return 0.0; + } + analysis.energy_curve.iter().copied().sum::() / analysis.energy_curve.len() as f32 +} + +fn plan_transition_diagnostics( + tracks: &[PlannedTrack], + options: DjMixOptions, + tools: &ProfessionalToolchain, +) -> Vec { + let transition_frames = planned_transition_frame_counts(tracks, options); + tracks + .windows(2) + .enumerate() + .map(|(index, pair)| { + let current = &pair[0]; + let next = &pair[1]; + let effective_bpm = ((current.analysis.bpm * current.speed_ratio + + next.analysis.bpm * next.speed_ratio) + * 0.5) + .clamp(MIN_BPM, MAX_BPM); + let requested_frames = + transition_frame_count(current, next, options.transition_bars); + let actual_frames = transition_frames[index]; + let duration_seconds = actual_frames as f32 / OUTPUT_SAMPLE_RATE as f32; + let recipe = TransitionRecipe::resolve( + options.bridge_mode, + transition_seed(current, next), + transition_should_hit_hard(current, next), + options.professional_tools && options.stem_separation && tools.demucs.is_some(), + keys_are_compatible(current, next), + ); + let mut warnings = Vec::new(); + let mut decisions = vec![ + format!( + "Aligned to {}-bar phrase boundary.", + options.transition_bars + ), + format!( + "Tempo target {:.2} BPM; Rubber Band R3 is preferred with built-in WSOLA fallback.", + effective_bpm + ), + format!("Transition recipe: {}.", recipe.diagnostic_name()), + "The bridge follows the complete overlap phrase instead of repeating a one-bar fragment." + .to_owned(), + "A low instrumental continuity bed remains active; transition gain never intentionally reaches zero." + .to_owned(), + "Outgoing and incoming lead vocals are never intentionally layered; the handoff is instrumental between vocal phrases." + .to_owned(), + ]; + if actual_frames < requested_frames { + decisions.push(format!( + "Transition shortened from {:.2} to {:.2} seconds to fit selected sections.", + requested_frames as f32 / OUTPUT_SAMPLE_RATE as f32, + duration_seconds + )); + } + if options.bass_swap { + decisions.push("Only one bass stem owns the low end around the handoff.".to_owned()); + } + if options.stem_separation && tools.demucs.is_some() { + decisions.push( + "Demucs detected: renderer attempts drums, bass, accompaniment and vocal separation with independent phrase gates." + .to_owned(), + ); + } else if options.stem_separation { + warnings.push( + "Stem-aware mixing requested, but Demucs was not detected; full-mix vocal guard used." + .to_owned(), + ); + } + if current.analysis.musical_key.is_none() || next.analysis.musical_key.is_none() { + warnings.push( + "Harmonic compatibility unavailable; automatic mode avoids assuming a key match." + .to_owned(), + ); + } + if current.analysis.bpm_confidence < 0.15 || next.analysis.bpm_confidence < 0.15 { + warnings.push("Low BPM confidence; transition may need manual review.".to_owned()); + } + if !options.professional_tools { + warnings.push( + "Professional tools disabled; built-in analysis, WSOLA and full-mix fallback used." + .to_owned(), + ); + } + TransitionDiagnostic { + from_title: current.track.title.clone(), + to_title: next.track.title.clone(), + style: recipe.diagnostic_name().to_owned(), + phrase_bars: options.transition_bars, + duration_seconds, + effective_bpm, + beat_alignment_error_ms: 1_000.0 / OUTPUT_SAMPLE_RATE as f32, + peak_reduction_db: transition_peak_reduction_db(current, next), + decisions, + warnings, + } + }) + .collect() +} + +fn analysis_availability_label(availability: &AnalysisAvailability) -> String { + match availability { + AnalysisAvailability::Unavailable { reason } => format!("unavailable: {reason}"), + } +} + +fn build_report( + output_path: &Path, + tracks: &[PlannedTrack], + transitions: Vec, + options: DjMixOptions, + measurement: OutputMeasurement, + timings: RenderTimings, + professional_tools: ProfessionalToolReport, +) -> MixReport { + let planned_duration_seconds = tracks + .iter() + .map(|track| { + (track.section_end_seconds - track.section_start_seconds) / track.speed_ratio.max(0.001) + }) + .sum::() + - transitions + .iter() + .map(|transition| transition.duration_seconds) + .sum::(); + let track_diagnostics = tracks + .iter() + .map(|track| TrackDiagnostic { + title: track.track.title.clone(), + path: track.track.path.display().to_string(), + bpm: track.analysis.bpm, + bpm_confidence: track.analysis.bpm_confidence, + beat_offset_seconds: track.analysis.beat_offset_seconds, + downbeat_offset_seconds: track.analysis.downbeat_offset_seconds, + integrated_lufs: track.analysis.integrated_lufs, + true_peak_dbfs: amplitude_to_db(track.analysis.true_peak), + musical_key: track.analysis.musical_key.clone(), + key_confidence: track.analysis.key_confidence, + vocal_analysis: analysis_availability_label(&track.analysis.vocal_profile), + analysis_backend: track.analysis.analysis_backend.clone(), + selected_section: format!("{:?}", track.track.section_mode), + section_start_seconds: track.section_start_seconds, + section_end_seconds: track.section_end_seconds, + section_reason: track.section_reason.clone(), + speed_ratio: track.speed_ratio, + gain_db: gain_to_db(track.gain), + }) + .collect(); + MixReport { + schema_version: ENGINE_SCHEMA_VERSION, + engine_version: ENGINE_VERSION.to_owned(), + analyzer_version: ANALYZER_VERSION.to_owned(), + output_path: output_path.display().to_string(), + requested_target_minutes: options.target_minutes, + planned_duration_seconds: planned_duration_seconds.max(0.0), + rendered_duration_seconds: measurement.duration_seconds, + integrated_lufs: measurement.integrated_lufs, + sample_peak_dbfs: measurement.sample_peak_dbfs, + true_peak_dbfs: measurement.true_peak_dbfs, + timings_ms: timings, + tracks: track_diagnostics, + transitions, + warnings: vec![ + "Section labels remain deterministic energy heuristics; Essentia supplies beat and key descriptors, not semantic song structure." + .to_owned(), + "Essentia, Rubber Band and Demucs are optional user-installed executables and are not bundled with Audio Orbit." + .to_owned(), + "When an external tool fails or is missing, export continues with deterministic built-in fallbacks." + .to_owned(), + ], + professional_tools, + } +} + +fn transition_frame_count(current: &PlannedTrack, next: &PlannedTrack, bars: u32) -> usize { + let current_bpm = (current.analysis.bpm * current.speed_ratio).clamp(MIN_BPM, MAX_BPM); + let next_bpm = (next.analysis.bpm * next.speed_ratio).clamp(MIN_BPM, MAX_BPM); + let mix_bpm = ((current_bpm + next_bpm) * 0.5).clamp(MIN_BPM, MAX_BPM); + let seconds = phrase_seconds(mix_bpm, bars); + (seconds * OUTPUT_SAMPLE_RATE as f32).round().max(1.0) as usize +} + +fn planned_output_section_frame_count(track: &PlannedTrack, style: DjMixStyle) -> usize { + let source_frames = section_frame_count(track); + match style { + DjMixStyle::Crossfade => source_frames, + DjMixStyle::SmartDj => { + ((source_frames as f32 / track.speed_ratio.max(0.001)).round() as usize).max(1) + } + } +} + +fn planned_transition_frame_counts(tracks: &[PlannedTrack], options: DjMixOptions) -> Vec { + if tracks.len() < 2 { + return Vec::new(); + } + + let section_frames = tracks + .iter() + .map(|track| planned_output_section_frame_count(track, options.style)) + .collect::>(); + let requested = tracks + .windows(2) + .map(|pair| transition_frame_count(&pair[0], &pair[1], options.transition_bars)) + .collect::>(); + allocate_transition_frame_counts(§ion_frames, &requested) +} + +fn allocate_transition_frame_counts(section_frames: &[usize], requested: &[usize]) -> Vec { + if section_frames.len() < 2 || requested.is_empty() { + return Vec::new(); + } + + debug_assert_eq!(requested.len(), section_frames.len() - 1); + let pair_count = requested.len().min(section_frames.len().saturating_sub(1)); + let mut remaining = section_frames.to_vec(); + let mut overlaps = Vec::with_capacity(pair_count); + + for index in 0..pair_count { + let following_requested = requested.get(index + 1).copied().unwrap_or(0); + let next_budget = incoming_transition_budget( + section_frames[index + 1], + requested[index], + following_requested, + ); + let overlap = requested[index].min(remaining[index]).min(next_budget); + overlaps.push(overlap); + remaining[index] = remaining[index].saturating_sub(overlap); + remaining[index + 1] = section_frames[index + 1].saturating_sub(overlap); + } + + overlaps +} + +fn incoming_transition_budget( + track_frames: usize, + incoming_requested: usize, + outgoing_requested: usize, +) -> usize { + if track_frames == 0 || incoming_requested == 0 { + return 0; + } + if outgoing_requested == 0 || track_frames == 1 { + return track_frames.min(incoming_requested); + } + + let total_requested = incoming_requested as u128 + outgoing_requested as u128; + let proportional = + (track_frames as u128 * incoming_requested as u128 / total_requested) as usize; + proportional + .clamp(1, track_frames - 1) + .min(incoming_requested) +} + +fn estimated_transition_seconds(tracks: &[(DjMixTrack, TrackAnalysis)], bars: u32) -> f32 { + if tracks.len() < 2 { + return 0.0; + } + let average_bpm = + tracks.iter().map(|(_, analysis)| analysis.bpm).sum::() / tracks.len() as f32; + phrase_seconds(average_bpm.clamp(MIN_BPM, MAX_BPM), bars) +} + +fn phrase_seconds(bpm: f32, bars: u32) -> f32 { + bars.max(1) as f32 * 4.0 * 60.0 / bpm.clamp(MIN_BPM, MAX_BPM) +} + +fn align_to_phrase(value: f32, origin: f32, phrase_seconds: f32, forward: bool) -> f32 { + if phrase_seconds <= 0.0 || !phrase_seconds.is_finite() { + return value.max(0.0); + } + let relative = (value - origin) / phrase_seconds; + let phrase = if forward { + relative.ceil() + } else { + relative.floor() + }; + (origin + phrase * phrase_seconds).max(0.0) +} + +fn section_frame_count(track: &PlannedTrack) -> usize { + ((track.section_end_seconds - track.section_start_seconds).max(0.0) * OUTPUT_SAMPLE_RATE as f32) + .round() as usize +} + +fn transition_peak_scale( + outgoing: &[[f32; 2]], + incoming: &[[f32; 2]], + outgoing_gain: f32, + incoming_gain: f32, +) -> f32 { + let frames = outgoing.len().min(incoming.len()); + let mut peak = 0.0f32; + for index in 0..frames { + let progress = normalized_progress(index, frames); + let out_fade = ((1.0 - progress) * std::f32::consts::FRAC_PI_2).sin(); + let in_fade = (progress * std::f32::consts::FRAC_PI_2).sin(); + for channel in 0..2 { + let sample = outgoing[index][channel] * outgoing_gain * out_fade + + incoming[index][channel] * incoming_gain * in_fade; + peak = peak.max(sample.abs()); + } + } + if peak > OUTPUT_PEAK_LIMIT { + OUTPUT_PEAK_LIMIT / peak + } else { + 1.0 + } +} + +fn transition_peak_reduction_db(current: &PlannedTrack, next: &PlannedTrack) -> f32 { + let estimated = current.analysis.true_peak as f32 * current.gain + + next.analysis.true_peak as f32 * next.gain; + if estimated > OUTPUT_PEAK_LIMIT { + gain_to_db(OUTPUT_PEAK_LIMIT / estimated) + } else { + 0.0 + } +} + +fn remove_local_mean(values: &mut [f32], radius: usize) { + if values.is_empty() || radius == 0 { + return; + } + let original = values.to_vec(); + let mut prefix = vec![0.0f32; original.len() + 1]; + for (index, value) in original.iter().enumerate() { + prefix[index + 1] = prefix[index] + value; + } + for index in 0..values.len() { + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(values.len()); + let mean = (prefix[end] - prefix[start]) / (end - start) as f32; + values[index] = (original[index] - mean).max(0.0); + } +} + +fn first_sustained_level(values: &[f32], threshold: f32, count: usize) -> Option { + if count == 0 || values.len() < count { + return None; + } + values + .windows(count) + .position(|window| window.iter().all(|value| *value >= threshold)) +} + +fn last_sustained_level(values: &[f32], threshold: f32, count: usize) -> Option { + if count == 0 || values.len() < count { + return None; + } + values + .windows(count) + .rposition(|window| window.iter().all(|value| *value >= threshold)) + .map(|index| index + count - 1) +} + +fn percentile(sorted: &[f32], percentile: f32) -> f32 { + if sorted.is_empty() { + return 0.0; + } + let index = ((sorted.len() - 1) as f32 * percentile.clamp(0.0, 1.0)).round() as usize; + sorted[index] +} + +fn average_energy_range(values: &[f32], start: usize, end: usize) -> f32 { + let start = start.min(values.len()); + let end = end.min(values.len()).max(start); + if end <= start { + return 0.0; + } + values[start..end].iter().sum::() / (end - start) as f32 +} + +fn seconds_to_energy_index(seconds: f32) -> usize { + (seconds.max(0.0) * ENERGY_RATE_HZ as f32).round() as usize +} + +fn load_analysis_cache() -> AnalysisCache { + let Some(path) = analysis_cache_path() else { + return AnalysisCache::default(); + }; + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|cache| cache.schema_version == ENGINE_SCHEMA_VERSION) + .unwrap_or_default() +} + +fn save_analysis_cache(cache: &AnalysisCache) { + let Some(path) = analysis_cache_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(bytes) = serde_json::to_vec(cache) { + let _ = fs::write(path, bytes); + } +} + +fn analysis_cache_path() -> Option { + app_data_dir().map(|directory| directory.join("dj-analysis-cache.json")) +} + +fn maximum_peak(analyzer: &EbuR128, true_peak: bool) -> Option { + (0..OUTPUT_CHANNELS as u32) + .filter_map(|channel| { + if true_peak { + analyzer.true_peak(channel).ok() + } else { + analyzer.sample_peak(channel).ok() + } + }) + .filter(|value| value.is_finite()) + .max_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal)) +} + +fn maximum_peak_dbfs(analyzer: &EbuR128, true_peak: bool) -> Option { + maximum_peak(analyzer, true_peak).and_then(amplitude_to_db) +} + +fn amplitude_to_db(value: f64) -> Option { + if value > 0.0 && value.is_finite() { + Some(20.0 * value.log10()) + } else { + None + } +} + +fn read_stereo_frame(source: &mut I) -> Option<[f32; 2]> +where + I: Iterator, +{ + let left = source.next()?; + let right = source.next().unwrap_or(left); + Some([left, right]) +} + +fn normalized_progress(index: usize, frames: usize) -> f32 { + if frames <= 1 { + 1.0 + } else { + index as f32 / (frames - 1) as f32 + } +} + +fn multiply_frame(frame: [f32; 2], gain: f32) -> [f32; 2] { + [frame[0] * gain, frame[1] * gain] +} + +fn limit_frame(frame: [f32; 2]) -> [f32; 2] { + let peak = frame[0].abs().max(frame[1].abs()); + if peak > OUTPUT_PEAK_LIMIT { + let gain = OUTPUT_PEAK_LIMIT / peak; + [frame[0] * gain, frame[1] * gain] + } else { + frame + } +} + +fn db_to_gain(db: f32) -> f32 { + 10.0f32.powf(db / 20.0) +} + +fn gain_to_db(gain: f32) -> f32 { + if gain > 0.0 { + 20.0 * gain.log10() + } else { + f32::NEG_INFINITY + } +} + +fn float_to_i16(value: f32) -> i16 { + (value.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16 +} + +fn ensure_mp3_extension(path: &Path) -> PathBuf { + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("mp3")) + { + path.to_path_buf() + } else { + path.with_extension("mp3") + } +} + +fn report_output_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("dj-mix.mp3"); + path.with_file_name(format!("{file_name}.dj-plan.json")) +} + +fn temporary_output_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("dj-mix.mp3"); + path.with_file_name(format!("{file_name}.part")) +} + +fn cleanup_temporary_files(audio: &Path, report: &Path) { + let _ = fs::remove_file(audio); + let _ = fs::remove_file(report); +} + +#[cfg(windows)] +fn lower_worker_priority() { + use windows_sys::Win32::System::Threading::{ + GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_BELOW_NORMAL, + }; + unsafe { + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); + } +} + +#[cfg(not(windows))] +fn lower_worker_priority() {} + +fn ensure_not_cancelled(cancel: &AtomicBool) -> Result<()> { + if cancel.load(AtomicOrdering::Relaxed) { + Err(anyhow!("DJ mix export cancelled.")) + } else { + Ok(()) + } +} + +fn send_progress(sender: &Sender, stage: String, progress: f32) { + let _ = sender.send(DjMixEvent::Progress { + stage, + progress: progress.clamp(0.0, 1.0), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn estimates_known_bpm_and_downbeat_from_impulses() { + let bpm = 120.0; + let lag = (ANALYSIS_RATE_HZ as f32 * 60.0 / bpm) as usize; + let mut onset = vec![0.0; ANALYSIS_RATE_HZ * 30]; + for (beat, index) in (7..onset.len()).step_by(lag).enumerate() { + onset[index] = if beat % 4 == 0 { 2.0 } else { 1.0 }; + } + let (estimated, estimated_lag, _) = estimate_bpm(&onset); + let beat_phase = estimate_beat_phase(&onset, estimated_lag); + let downbeat = estimate_downbeat_phase(&onset, beat_phase, estimated_lag); + assert!((estimated - bpm).abs() < 1.0, "estimated {estimated}"); + assert_eq!(downbeat.abs_diff(7) % (estimated_lag * 4), 0); + } + + #[test] + fn analyzes_generated_wav_fixture() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "audio-orbit-generated-dj-fixture-{}-{unique}.wav", + std::process::id() + )); + write_generated_click_fixture(&path, 120.0, 12.0).expect("write fixture"); + let cancel = AtomicBool::new(false); + let analysis = analyze_track(&path, &ProfessionalToolchain::default(), &cancel) + .expect("analyze fixture"); + let _ = fs::remove_file(path); + assert!( + (analysis.bpm - 120.0).abs() < 2.0, + "estimated {}", + analysis.bpm + ); + assert!(analysis.integrated_lufs.is_some()); + assert!(analysis.true_peak > 0.0); + assert!(!analysis.sections.is_empty()); + } + + #[test] + fn planner_is_deterministic_and_phrase_aligned() { + let options = DjMixOptions::default(); + let input = vec![ + test_analysis_track("first", 120.0), + test_analysis_track("second", 124.0), + ]; + let first = plan_tracks(input.clone(), options).expect("first plan"); + let second = plan_tracks(input, options).expect("second plan"); + assert_eq!(first.len(), second.len()); + for (left, right) in first.iter().zip(second.iter()) { + assert_eq!(left.track.title, right.track.title); + assert!( + (left.section_start_seconds - right.section_start_seconds).abs() < f32::EPSILON + ); + let phrase = phrase_seconds(left.analysis.bpm, options.transition_bars); + let relative = + (left.section_start_seconds - left.analysis.downbeat_offset_seconds) / phrase; + assert!( + (relative - relative.round()).abs() < 0.001 + || left.section_start_seconds <= left.analysis.leading_silence_seconds + 0.001 + ); + } + } + + #[test] + fn favorite_range_accepts_short_usable_selection() { + let mut track = DjMixTrack::new(PathBuf::from("favorite"), "favorite".to_owned()); + track.section_mode = DjTrackSectionMode::FavoriteRange; + track.favorite_start_seconds = 50.0; + track.favorite_end_seconds = 55.0; + let analysis = test_analysis(120.0); + let result = select_track_section(&track, &analysis, 60.0, 16); + assert!(result.is_ok()); + } + + #[test] + fn favorite_range_rejects_empty_selection() { + let mut track = DjMixTrack::new(PathBuf::from("favorite"), "favorite".to_owned()); + track.section_mode = DjTrackSectionMode::FavoriteRange; + track.favorite_start_seconds = 55.0; + track.favorite_end_seconds = 55.0; + let analysis = test_analysis(120.0); + let result = select_track_section(&track, &analysis, 60.0, 16); + assert!(result.is_err()); + } + + #[test] + fn harmonic_key_rules_accept_same_relative_and_fifth_keys() { + assert!(harmonic_keys_are_compatible("C major", "C major")); + assert!(harmonic_keys_are_compatible("C major", "A minor")); + assert!(harmonic_keys_are_compatible("C major", "G major")); + assert!(!harmonic_keys_are_compatible("C major", "F# major")); + } + + #[test] + fn smart_order_keeps_first_track_and_clusters_bpm() { + let ordered = smart_order(vec![ + test_analysis_track("first", 120.0), + test_analysis_track("far", 150.0), + test_analysis_track("near", 124.0), + ]); + assert_eq!(ordered[0].0.title, "first"); + assert_eq!(ordered[1].0.title, "near"); + } + + #[test] + fn crossfade_mode_keeps_original_playback_speed() { + let analyzed = vec![ + test_analysis_track("first", 100.0), + test_analysis_track("second", 140.0), + ]; + let mut options = DjMixOptions::default(); + options.style = DjMixStyle::Crossfade; + options.smart_order = false; + let planned = plan_tracks(analyzed, options).expect("plan"); + assert!(planned + .iter() + .all(|track| (track.speed_ratio - 1.0).abs() < f32::EPSILON)); + } + + #[test] + fn smart_dj_mode_caps_pairwise_tempo_sync() { + let analyzed = vec![ + test_analysis_track("first", 100.0), + test_analysis_track("second", 140.0), + ]; + let mut options = DjMixOptions::default(); + options.style = DjMixStyle::SmartDj; + options.smart_order = false; + let planned = plan_tracks(analyzed, options).expect("plan"); + assert!((planned[0].speed_ratio - 1.0).abs() < f32::EPSILON); + assert!((planned[1].speed_ratio - (1.0 - MAX_SMART_TEMPO_CHANGE)).abs() < 0.0001); + } + + #[test] + fn adaptive_transitions_preserve_middle_track_for_both_sides() { + let overlaps = allocate_transition_frame_counts(&[100, 100, 100], &[100, 100]); + assert_eq!(overlaps, vec![50, 50]); + assert_eq!(overlaps[0] + overlaps[1], 100); + } + + #[test] + fn adaptive_transitions_shorten_to_available_sections() { + let overlaps = allocate_transition_frame_counts(&[20, 30, 40], &[100, 100]); + assert_eq!(overlaps, vec![15, 15]); + assert!(overlaps[0] <= 20); + assert!(overlaps[0] + overlaps[1] <= 30); + assert!(overlaps[1] <= 40); + } + + #[test] + fn final_transition_can_use_remaining_last_track() { + let overlaps = allocate_transition_frame_counts(&[20, 50], &[100]); + assert_eq!(overlaps, vec![20]); + } + + #[test] + fn transition_peak_scale_prevents_overflow() { + let outgoing = vec![[0.9, 0.9]; 100]; + let incoming = vec![[0.9, 0.9]; 100]; + let scale = transition_peak_scale(&outgoing, &incoming, 1.0, 1.0); + assert!(scale < 1.0); + for index in 0..100 { + let progress = normalized_progress(index, 100); + let out_fade = ((1.0 - progress) * std::f32::consts::FRAC_PI_2).sin(); + let in_fade = (progress * std::f32::consts::FRAC_PI_2).sin(); + let mixed = 0.9 * scale * out_fade + 0.9 * scale * in_fade; + assert!(mixed <= OUTPUT_PEAK_LIMIT + 0.001); + } + } + + #[test] + fn mp3_and_report_paths_are_deterministic() { + let output = ensure_mp3_extension(Path::new("mix")); + assert_eq!(output, PathBuf::from("mix.mp3")); + assert_eq!( + report_output_path(&output), + PathBuf::from("mix.mp3.dj-plan.json") + ); + } + + #[test] + fn mp3_stream_writer_creates_output_and_measurement() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "audio-orbit-dj-writer-{}-{unique}.mp3", + std::process::id() + )); + let mut writer = Mp3StreamWriter::create(&path, 192).expect("create MP3 writer"); + for index in 0..OUTPUT_SAMPLE_RATE { + let phase = index as f32 / OUTPUT_SAMPLE_RATE as f32 * 440.0 * std::f32::consts::TAU; + let sample = phase.sin() * 0.2; + writer.write_frame([sample, sample]).expect("encode frame"); + } + let measurement = writer.finish().expect("finish MP3"); + let bytes = fs::read(&path).expect("read MP3"); + let _ = fs::remove_file(path); + assert!(bytes.len() > 1_000); + assert!(measurement.integrated_lufs.is_some()); + assert!(measurement.true_peak_dbfs.is_some()); + } + + fn test_analysis_track(title: &str, bpm: f32) -> (DjMixTrack, TrackAnalysis) { + ( + DjMixTrack::new(PathBuf::from(title), title.to_owned()), + test_analysis(bpm), + ) + } + + fn test_analysis(bpm: f32) -> TrackAnalysis { + TrackAnalysis { + bpm, + bpm_confidence: 0.8, + beat_offset_seconds: 0.0, + downbeat_offset_seconds: 0.0, + leading_silence_seconds: 0.0, + trailing_silence_seconds: 0.0, + integrated_lufs: Some(-14.0), + sample_peak: 0.5, + true_peak: 0.55, + duration_seconds: 180.0, + energy_curve: vec![0.1; 360], + beat_positions_seconds: (0..360).map(|beat| beat as f32 * 0.5).collect(), + sections: vec![DetectedSection { + kind: SectionKind::Main, + start_seconds: 16.0, + end_seconds: 164.0, + energy: 0.1, + confidence: 0.7, + }], + musical_key: None, + key_confidence: None, + vocal_profile: AnalysisAvailability::Unavailable { + reason: "test".to_owned(), + }, + analysis_backend: "test".to_owned(), + } + } + + fn write_generated_click_fixture(path: &Path, bpm: f32, seconds: f32) -> Result<()> { + let sample_rate = OUTPUT_SAMPLE_RATE; + let channels = OUTPUT_CHANNELS; + let frames = (sample_rate as f32 * seconds) as u32; + let data_bytes = frames * channels as u32 * 2; + let byte_rate = sample_rate * channels as u32 * 2; + let block_align = channels * 2; + let mut file = BufWriter::new(File::create(path)?); + file.write_all(b"RIFF")?; + file.write_all(&(36 + data_bytes).to_le_bytes())?; + file.write_all(b"WAVEfmt ")?; + file.write_all(&16u32.to_le_bytes())?; + file.write_all(&1u16.to_le_bytes())?; + file.write_all(&channels.to_le_bytes())?; + file.write_all(&sample_rate.to_le_bytes())?; + file.write_all(&byte_rate.to_le_bytes())?; + file.write_all(&block_align.to_le_bytes())?; + file.write_all(&16u16.to_le_bytes())?; + file.write_all(b"data")?; + file.write_all(&data_bytes.to_le_bytes())?; + let beat_frames = (sample_rate as f32 * 60.0 / bpm).round() as u32; + for frame in 0..frames { + let beat_position = frame % beat_frames.max(1); + let envelope = if beat_position < 500 { + 1.0 - beat_position as f32 / 500.0 + } else { + 0.0 + }; + let tone = (frame as f32 * 0.13).sin() * envelope * 0.65; + let sample = float_to_i16(tone); + file.write_all(&sample.to_le_bytes())?; + file.write_all(&sample.to_le_bytes())?; + } + file.flush()?; + Ok(()) + } +} diff --git a/src/dsp.rs b/src/dsp.rs index 61da3ca..fd9e3ce 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -162,7 +162,9 @@ pub fn render_orbit_to_stereo_with_cached_analysis( let mono = downmix_to_mono(input_samples, channels, frame_count); let mut start_frame = ((start_seconds.max(0.0) * sample_rate as f32) as usize).min(frame_count); let (waveform, waveform_brightness) = cached_waveform - .filter(|(waveform, waveform_brightness)| !waveform.is_empty() && !waveform_brightness.is_empty()) + .filter(|(waveform, waveform_brightness)| { + !waveform.is_empty() && !waveform_brightness.is_empty() + }) .unwrap_or_else(|| spectrum_waveform(&mono, sample_rate, WAVEFORM_POINTS)); let output_level = settings.output_level_percent.clamp(1, 100) as f32 / 100.0; @@ -292,7 +294,12 @@ pub fn render_orbit_to_stereo_with_cached_analysis( smoothed_backness = smooth_value(smoothed_backness, target.backness, smoothing_coeff); front_presence_state = high_passish(front_presence_state, source_sample); - rear_low_pass_state = rear_low_pass(rear_low_pass_state, source_sample, smoothed_backness, depth_amount); + rear_low_pass_state = rear_low_pass( + rear_low_pass_state, + source_sample, + smoothed_backness, + depth_amount, + ); let (left, right) = match settings.mode { OrbitMode::SmoothStereoOrbit => render_smooth_stereo_frame( @@ -382,7 +389,7 @@ fn automatic_silence_floor(samples: &[f32]) -> f32 { .max(p12 * 3.0) .max(p25 * 0.85) .max(peak * 0.0018)) - .clamp(0.0025, 0.020) + .clamp(0.0025, 0.020) } fn db_to_linear_threshold(db: i16) -> f32 { @@ -443,8 +450,13 @@ fn detect_silence_ranges( for (window_index, chunk) in mono.chunks(window_frames).enumerate() { let start = window_index * window_frames; let end = (start + chunk.len()).min(mono.len()); - let rms = (chunk.iter().map(|sample| sample * sample).sum::() / chunk.len().max(1) as f32).sqrt(); - let peak = chunk.iter().map(|sample| sample.abs()).fold(0.0_f32, f32::max); + let rms = (chunk.iter().map(|sample| sample * sample).sum::() + / chunk.len().max(1) as f32) + .sqrt(); + let peak = chunk + .iter() + .map(|sample| sample.abs()) + .fold(0.0_f32, f32::max); let silent = rms <= silence_rms_gate && peak <= silence_peak_gate; if silent { @@ -468,7 +480,12 @@ fn detect_silence_ranges( if let Some(start) = candidate_start.take() { let silent_frames = candidate_last_silent_end.saturating_sub(start); if silent_frames >= min_silent_frames { - push_silence_range(&mut ranges, start, candidate_last_silent_end, edge_padding_frames); + push_silence_range( + &mut ranges, + start, + candidate_last_silent_end, + edge_padding_frames, + ); } } candidate_last_silent_end = 0; @@ -478,7 +495,8 @@ fn detect_silence_ranges( if let Some(start) = candidate_start.take() { let silent_frames = mono.len().saturating_sub(start); if silent_frames >= min_silent_frames - || (trim_end_regardless_of_duration && candidate_last_silent_end >= mono.len().saturating_sub(window_frames)) + || (trim_end_regardless_of_duration + && candidate_last_silent_end >= mono.len().saturating_sub(window_frames)) { push_silence_range(&mut ranges, start, mono.len(), edge_padding_frames); } @@ -544,7 +562,10 @@ fn render_plain_stereo( } else { ( input_samples.get(offset).copied().unwrap_or(source_sample), - input_samples.get(offset + 1).copied().unwrap_or(source_sample), + input_samples + .get(offset + 1) + .copied() + .unwrap_or(source_sample), ) }; @@ -608,7 +629,6 @@ fn apply_skip_boundary_smoothing( } } - #[derive(Clone, Copy)] struct OrbitPosition { pan: f32, @@ -671,7 +691,8 @@ fn render_surround_frame( let front_mix = frontness * depth_amount; let front_sample = source_sample + front_presence_state * front_mix * 0.30; - let rear_sample = (source_sample * (1.0 - rear_mix * 0.70)) + (rear_low_pass_state * rear_mix * 1.05); + let rear_sample = + (source_sample * (1.0 - rear_mix * 0.70)) + (rear_low_pass_state * rear_mix * 1.05); let spatial_sample = front_sample * (1.0 - rear_mix) + rear_sample * rear_mix; let delay_base = MAX_STEREO_DELAY_SECONDS + MAX_SURROUND_DELAY_SECONDS * rear_mix; diff --git a/src/file_associations.rs b/src/file_associations.rs new file mode 100644 index 0000000..40b184b --- /dev/null +++ b/src/file_associations.rs @@ -0,0 +1,217 @@ +use std::path::Path; + +pub const SUPPORTED_AUDIO_EXTENSIONS: &[&str] = &[ + "mp3", "wav", "flac", "ogg", "opus", "m4a", "mp4", "aac", "aiff", "aif", "ape", "wv", +]; + +#[cfg(windows)] +mod platform { + use super::*; + use std::{ + ffi::{OsStr, OsString}, + process::Command, + }; + + const PROG_ID: &str = "AudioOrbit.Audio"; + const REGISTERED_APP_NAME: &str = "Audio Orbit"; + const CAPABILITIES_PATH: &str = r"Software\Audio Orbit\Capabilities"; + + fn run_reg(args: I) -> Result<(), String> + where + I: IntoIterator, + S: AsRef, + { + let output = Command::new("reg.exe") + .args(args) + .output() + .map_err(|error| format!("failed to start reg.exe: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let message = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if message.is_empty() { + format!("reg.exe exited with {}", output.status) + } else { + message + }) + } + } + + fn add_value( + key: &str, + name: Option<&str>, + value_type: &str, + data: &str, + ) -> Result<(), String> { + let mut args = vec![OsString::from("add"), OsString::from(key)]; + match name { + Some(name) => { + args.push(OsString::from("/v")); + args.push(OsString::from(name)); + } + None => args.push(OsString::from("/ve")), + } + args.extend([ + OsString::from("/t"), + OsString::from(value_type), + OsString::from("/d"), + OsString::from(data), + OsString::from("/f"), + ]); + run_reg(args) + } + + fn delete_key(key: &str) { + let _ = run_reg(["delete", key, "/f"]); + } + + fn delete_value(key: &str, name: &str) { + let _ = run_reg(["delete", key, "/v", name, "/f"]); + } + + pub fn register(executable: &Path) -> Result<(), String> { + let executable = executable + .canonicalize() + .unwrap_or_else(|_| executable.to_path_buf()); + let executable_text = executable.to_string_lossy(); + let quoted_executable = format!("\"{executable_text}\""); + let open_command = format!("{quoted_executable} \"%1\""); + let icon_value = format!("{quoted_executable},0"); + + add_value( + &format!(r"HKCU\Software\Classes\{PROG_ID}"), + None, + "REG_SZ", + "Audio Orbit audio file", + )?; + add_value( + &format!(r"HKCU\Software\Classes\{PROG_ID}\DefaultIcon"), + None, + "REG_SZ", + &icon_value, + )?; + add_value( + &format!(r"HKCU\Software\Classes\{PROG_ID}\shell\open\command"), + None, + "REG_SZ", + &open_command, + )?; + + let application_key = r"HKCU\Software\Classes\Applications\audio-orbit.exe"; + add_value( + application_key, + Some("FriendlyAppName"), + "REG_SZ", + REGISTERED_APP_NAME, + )?; + add_value( + &format!(r"{application_key}\shell\open\command"), + None, + "REG_SZ", + &open_command, + )?; + + let capabilities_key = format!(r"HKCU\{CAPABILITIES_PATH}"); + add_value( + &capabilities_key, + Some("ApplicationName"), + "REG_SZ", + REGISTERED_APP_NAME, + )?; + add_value( + &capabilities_key, + Some("ApplicationDescription"), + "REG_SZ", + "Lightweight local music player and DJ mix exporter", + )?; + + for extension in SUPPORTED_AUDIO_EXTENSIONS { + let extension = format!(".{extension}"); + add_value( + &format!(r"{capabilities_key}\FileAssociations"), + Some(&extension), + "REG_SZ", + PROG_ID, + )?; + add_value( + &format!(r"HKCU\Software\Classes\{extension}\OpenWithProgids"), + Some(PROG_ID), + "REG_SZ", + "", + )?; + add_value( + &format!(r"{application_key}\SupportedTypes"), + Some(&extension), + "REG_SZ", + "", + )?; + } + + add_value( + r"HKCU\Software\RegisteredApplications", + Some(REGISTERED_APP_NAME), + "REG_SZ", + CAPABILITIES_PATH, + )?; + Ok(()) + } + + pub fn unregister() -> Result<(), String> { + delete_value(r"HKCU\Software\RegisteredApplications", REGISTERED_APP_NAME); + for extension in SUPPORTED_AUDIO_EXTENSIONS { + let extension = format!(".{extension}"); + delete_value( + &format!(r"HKCU\Software\Classes\{extension}\OpenWithProgids"), + PROG_ID, + ); + } + delete_key(r"HKCU\Software\Audio Orbit\Capabilities"); + delete_key(r"HKCU\Software\Classes\Applications\audio-orbit.exe"); + delete_key(&format!(r"HKCU\Software\Classes\{PROG_ID}")); + Ok(()) + } + + pub fn is_registered() -> bool { + Command::new("reg.exe") + .args([ + "query", + r"HKCU\Software\RegisteredApplications", + "/v", + REGISTERED_APP_NAME, + ]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + pub fn open_default_apps_settings() -> Result<(), String> { + Command::new("explorer.exe") + .arg("ms-settings:defaultapps?registeredAppUser=Audio%20Orbit") + .spawn() + .map(|_| ()) + .map_err(|error| format!("failed to open Windows Default Apps settings: {error}")) + } +} + +#[cfg(not(windows))] +mod platform { + use super::*; + + pub fn register(_executable: &Path) -> Result<(), String> { + Err("File association registration is available on Windows only.".to_owned()) + } + + pub fn unregister() -> Result<(), String> { + Err("File association registration is available on Windows only.".to_owned()) + } + + pub fn is_registered() -> bool { + false + } + + pub fn open_default_apps_settings() -> Result<(), String> { + Err("Windows Default Apps settings are available on Windows only.".to_owned()) + } +} + +pub use platform::{is_registered, open_default_apps_settings, register, unregister}; diff --git a/src/folder_watcher.rs b/src/folder_watcher.rs index c43e7dd..ee93d7c 100644 --- a/src/folder_watcher.rs +++ b/src/folder_watcher.rs @@ -30,9 +30,8 @@ impl FolderWatcher { use windows_sys::Win32::{ Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, Storage::FileSystem::{ - CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, - FILE_LIST_DIRECTORY, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - OPEN_EXISTING, + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }, System::Threading::{CreateEventW, SetEvent}, }; @@ -111,11 +110,9 @@ impl FolderWatcher { let directory_handle = ThreadOwnedHandle( thread_directory_handle as windows_sys::Win32::Foundation::HANDLE, ); - let stop_event = - thread_stop_event as windows_sys::Win32::Foundation::HANDLE; - let io_event = ThreadOwnedHandle( - thread_io_event as windows_sys::Win32::Foundation::HANDLE, - ); + let stop_event = thread_stop_event as windows_sys::Win32::Foundation::HANDLE; + let io_event = + ThreadOwnedHandle(thread_io_event as windows_sys::Win32::Foundation::HANDLE); run_watcher_thread( thread_root, directory_handle.get(), @@ -124,8 +121,7 @@ impl FolderWatcher { sender, context, ); - }) - { + }) { Ok(thread) => thread, Err(error) => { // SAFETY: Thread did not start, so all handles remain exclusively @@ -188,8 +184,8 @@ fn run_watcher_thread( ReadDirectoryChangesW, FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_FILE_NAME, }, System::{ - IO::{GetOverlappedResult, OVERLAPPED}, Threading::{ResetEvent, WaitForMultipleObjects, INFINITE}, + IO::{GetOverlappedResult, OVERLAPPED}, }, }; @@ -239,9 +235,8 @@ fn run_watcher_thread( } // SAFETY: handles contains two valid event handles and remains alive for call. - let wait_result = unsafe { - WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, INFINITE) - }; + let wait_result = + unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, INFINITE) }; match wait_result { WAIT_OBJECT_0_VALUE => { cancel_pending_read(directory_handle, &overlapped); @@ -251,12 +246,7 @@ fn run_watcher_thread( let mut bytes_returned = 0; // SAFETY: OVERLAPPED belongs to completed request on directory_handle. let completed = unsafe { - GetOverlappedResult( - directory_handle, - &overlapped, - &mut bytes_returned, - 0, - ) + GetOverlappedResult(directory_handle, &overlapped, &mut bytes_returned, 0) }; if completed == 0 { const ERROR_NOTIFY_ENUM_DIR_VALUE: i32 = 1022; @@ -318,7 +308,10 @@ fn run_watcher_thread( send_failure( &sender, &context, - format!("invalid folder watcher data for {}: {error}", root.display()), + format!( + "invalid folder watcher data for {}: {error}", + root.display() + ), ); break; } diff --git a/src/main.rs b/src/main.rs index 307069e..30837ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,35 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod app; mod audio_player; mod config; +mod dj_mix; mod dsp; -mod icon; +#[cfg(windows)] +mod file_associations; mod folder_watcher; +mod icon; mod media_keys; mod single_instance; mod spectrum_waveform; +mod time_stretch; mod ui_icons; mod updater; -mod app; #[cfg(debug_assertions)] use crate::app::dev_metrics::{DevMetricsNativeWindowHandle, DevMetricsPanelState}; use crate::{ - audio_player::{current_default_output_device_name, AudioPlayer, PlaybackInfo, PreparedPlayback, RadioVisualizerFrame}, + audio_player::{ + current_default_output_device_name, AudioPlayer, PlaybackInfo, PreparedPlayback, + RadioVisualizerFrame, + }, config::{ - app_data_dir, app_version_label, default_backup_file_name, display_file_name, export_state_zip, - import_state_zip, is_recursive_scan_link, is_supported_audio_file, load_state, path_is_same_or_descendant, - path_key, same_path, save_state, scan_audio_folder, LastPlayedTrack, - PlaybackSession, Playlist, PlaylistKind, RadioStation, RepeatMode, SavedState, Track, WindowGeometry, FAVORITES_PLAYLIST_NAME, + app_data_dir, app_version_label, default_backup_file_name, display_file_name, + export_state_zip, import_state_zip, is_recursive_scan_link, is_supported_audio_file, + load_state, path_is_same_or_descendant, path_key, same_path, save_state, scan_audio_folder, + LastPlayedTrack, PlaybackSession, Playlist, PlaylistKind, RadioStation, RepeatMode, + SavedState, Track, WindowGeometry, FAVORITES_PLAYLIST_NAME, }, dsp::{DspSettings, OrbitMode}, }; @@ -34,7 +42,7 @@ use std::{ io::Read, path::{Path, PathBuf}, process::Command, - sync::{mpsc, Arc}, + sync::{atomic::AtomicBool, mpsc, Arc}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -74,7 +82,10 @@ fn default_window_size_for_mode(player_only_mode: bool) -> egui::Vec2 { } } -fn saved_window_geometry_for_mode(state: &SavedState, player_only_mode: bool) -> Option { +fn saved_window_geometry_for_mode( + state: &SavedState, + player_only_mode: bool, +) -> Option { let geometry = if player_only_mode { state.ui.player_only_window_geometry } else { @@ -92,7 +103,8 @@ fn main() -> eframe::Result<()> { return app::dev_metrics::run_dev_metrics_process(config); } - let _single_instance_guard = match single_instance::acquire() { + let startup_audio_files = command_line_audio_files(); + let _single_instance_guard = match single_instance::acquire(&startup_audio_files) { Ok(Some(guard)) => guard, Ok(None) => return Ok(()), Err(error) => { @@ -128,11 +140,23 @@ fn main() -> eframe::Result<()> { Box::new(move |creation_context| { ui_icons::install(&creation_context.egui_ctx); configure_app_style(&creation_context.egui_ctx); - Ok(Box::new(AudioOrbitApp::new(state))) + let mut app = AudioOrbitApp::new(state); + if !startup_audio_files.is_empty() { + app.open_audio_files_in_temporary_playlist(startup_audio_files.clone(), true); + } + Ok(Box::new(app)) }), ) } +fn command_line_audio_files() -> Vec { + std::env::args_os() + .skip(1) + .map(PathBuf::from) + .filter(|path| path.is_file() && is_supported_audio_file(path)) + .collect() +} + fn configure_app_style(context: &egui::Context) { let mut style = (*context.style()).clone(); style @@ -146,7 +170,12 @@ fn initial_window_size(state: &SavedState) -> egui::Vec2 { let min_size = min_window_size_for_mode(player_only_mode); saved_window_geometry_for_mode(state, player_only_mode) - .map(|geometry| egui::vec2(geometry.width.max(min_size.x), geometry.height.max(min_size.y))) + .map(|geometry| { + egui::vec2( + geometry.width.max(min_size.x), + geometry.height.max(min_size.y), + ) + }) .unwrap_or_else(|| default_window_size_for_mode(player_only_mode)) } @@ -156,7 +185,6 @@ fn initial_window_position(state: &SavedState) -> Option { .map(|geometry| egui::pos2(geometry.x, geometry.y)) } - #[derive(Clone, Debug)] struct PendingTrackSwitch { switch_at: Instant, @@ -283,7 +311,6 @@ struct PendingLibrarySyncResult { folder_results: Vec, } - #[derive(Clone, Debug)] enum DetailsModal { Track(PathBuf), @@ -315,6 +342,124 @@ enum TrackFileOperationResult { }, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DjTrackSectionMode { + AutoHighlight, + FullTrack, + FavoriteRange, +} + +#[derive(Clone, Debug)] +struct DjMixTrack { + path: PathBuf, + title: String, + section_mode: DjTrackSectionMode, + favorite_start_seconds: f32, + favorite_end_seconds: f32, +} + +impl DjMixTrack { + fn new(path: PathBuf, title: String) -> Self { + Self { + path, + title, + section_mode: DjTrackSectionMode::AutoHighlight, + favorite_start_seconds: 0.0, + favorite_end_seconds: 60.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DjMixStyle { + Crossfade, + SmartDj, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DjBridgeMode { + Auto, + DrumSwap, + HarmonicBridge, + EchoDrop, + StemMashup, + Custom, +} + +#[derive(Clone, Copy, Debug)] +struct DjMixOptions { + style: DjMixStyle, + smart_order: bool, + normalize_loudness: bool, + bass_swap: bool, + professional_tools: bool, + stem_separation: bool, + bridge_mode: DjBridgeMode, + bridge_start_seconds: f32, + bridge_loop_seconds: f32, + bridge_level: f32, + transition_bars: u32, + target_minutes: f32, + bitrate_kbps: u32, +} + +impl Default for DjMixOptions { + fn default() -> Self { + Self { + style: DjMixStyle::SmartDj, + smart_order: true, + normalize_loudness: true, + bass_swap: true, + professional_tools: true, + stem_separation: true, + bridge_mode: DjBridgeMode::Auto, + bridge_start_seconds: 0.0, + bridge_loop_seconds: 4.0, + bridge_level: 0.72, + transition_bars: 16, + target_minutes: 15.0, + bitrate_kbps: 256, + } + } +} + +#[derive(Clone, Debug)] +struct DjMixModalState { + tracks: Vec, + options: DjMixOptions, + custom_bridge_path: Option, + stage: String, + progress: f32, + output_path: Option, + report_path: Option, + diagnostics_summary: Option, + professional_tool_status: String, + completed: bool, + started_at: Option, + last_progress_at: Option, + preview_track_path: Option, + preview_stop_seconds: Option, +} + +#[derive(Clone, Debug)] +enum DjMixEvent { + Progress { + stage: String, + progress: f32, + }, + Completed { + output_path: PathBuf, + report_path: PathBuf, + track_count: usize, + duration_seconds: f32, + integrated_lufs: Option, + true_peak_dbfs: Option, + diagnostics_summary: String, + }, + Cancelled, + Failed(String), +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum MainContentTab { Music, @@ -410,6 +555,8 @@ struct AudioOrbitApp { pending_folder_scan_receiver: Option>>, pending_library_sync_receiver: Option>, pending_track_file_operation_receiver: Option>, + dj_mix_event_receiver: Option>, + dj_mix_cancel_flag: Option>, folder_watcher: Option, folder_watcher_target_key: Option, pending_folder_watch_sync_at: Option, @@ -426,6 +573,7 @@ struct AudioOrbitApp { pending_new_playlist_tracks: Vec, pending_track_delete_confirmation: Option, pending_track_delete_confirmation_text: String, + dj_mix_modal: Option, active_panel_modal: Option, panel_modal_history: Vec, details_modal: Option, @@ -477,6 +625,9 @@ struct AudioOrbitApp { last_update_check: Option, update_check_started_at: Option, update_install_started_at: Option, + last_external_open_request_poll: Instant, + #[cfg(windows)] + file_associations_registered: bool, #[cfg(debug_assertions)] dev_metrics: DevMetricsPanelState, #[cfg(debug_assertions)] @@ -485,11 +636,6 @@ struct AudioOrbitApp { dev_metrics_window: Option, } - - - - - fn media_key_status_message( registered: &[media_keys::MediaKeyCommand], failed: &[media_keys::MediaKeyCommand], @@ -518,11 +664,7 @@ fn fetch_radio_stream_metadata(url: &str) -> Option { .build() .ok()?; - let mut response = client - .get(url) - .header("Icy-MetaData", "1") - .send() - .ok()?; + let mut response = client.get(url).header("Icy-MetaData", "1").send().ok()?; let headers = response.headers().clone(); let station_name = headers @@ -736,8 +878,12 @@ fn clean_radio_metadata_value(value: &str) -> String { .to_owned() } - -fn search_icon_text_button(ui: &mut egui::Ui, enabled: bool, icon: Icon, text: &str) -> egui::Response { +fn search_icon_text_button( + ui: &mut egui::Ui, + enabled: bool, + icon: Icon, + text: &str, +) -> egui::Response { let icon_text = ui_icons::icon(icon); let icon_font = egui::FontId::proportional(14.0); let text_font = egui::TextStyle::Button.resolve(ui.style()); @@ -748,15 +894,19 @@ fn search_icon_text_button(ui: &mut egui::Ui, enabled: bool, icon: Icon, text: & &icon_text, icon_font.clone(), ui.visuals().widgets.inactive.fg_stroke.color, - ).ceil(); + ) + .ceil(); let text_width = text_width( ui, text, text_font.clone(), ui.visuals().widgets.inactive.fg_stroke.color, - ).ceil(); + ) + .ceil(); let icon_gap = 5.0; - let button_width = (horizontal_padding * 2.0 + icon_width + icon_gap + text_width).ceil().max(44.0); + let button_width = (horizontal_padding * 2.0 + icon_width + icon_gap + text_width) + .ceil() + .max(44.0); let response = ui.add_enabled( enabled, @@ -830,7 +980,13 @@ fn text_width(ui: &egui::Ui, value: &str, font_id: egui::FontId, color: egui::Co .width() } -fn ellipsize_to_width_exact(ui: &egui::Ui, value: &str, width: f32, font_id: egui::FontId, color: egui::Color32) -> String { +fn ellipsize_to_width_exact( + ui: &egui::Ui, + value: &str, + width: f32, + font_id: egui::FontId, + color: egui::Color32, +) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return String::new(); @@ -956,7 +1112,14 @@ fn same_text(left: &str, right: &str) -> bool { } fn ensure_state_is_valid(state: &mut SavedState) { - if !state.playlists.iter().any(|playlist| playlist.kind == PlaylistKind::Favorites) { + state + .playlists + .retain(|playlist| playlist.kind != PlaylistKind::Temporary); + if !state + .playlists + .iter() + .any(|playlist| playlist.kind == PlaylistKind::Favorites) + { state.playlists.insert(0, Playlist::favorites()); } @@ -969,10 +1132,16 @@ fn ensure_state_is_valid(state: &mut SavedState) { playlist.ensure_favorite_added_sequences(); playlist.ensure_folder_group_contiguity(); playlist.set_selected_group(playlist.selected_group.clone()); - let track_paths: Vec = playlist.tracks.iter().map(|track| track.path.clone()).collect(); - playlist - .repeat_selection - .retain(|selected_path| track_paths.iter().any(|track_path| same_path(track_path, selected_path))); + let track_paths: Vec = playlist + .tracks + .iter() + .map(|track| track.path.clone()) + .collect(); + playlist.repeat_selection.retain(|selected_path| { + track_paths + .iter() + .any(|track_path| same_path(track_path, selected_path)) + }); let mut deduped_repeat_selection: Vec = Vec::new(); for selected_path in playlist.repeat_selection.drain(..) { if !deduped_repeat_selection @@ -992,9 +1161,13 @@ fn ensure_state_is_valid(state: &mut SavedState) { if state.selected_playlist_index >= state.playlists.len() { state.selected_playlist_index = 0; } + state.playlists.push(Playlist::temporary()); if state.profiles.is_empty() { - state.profiles.push(config::DspProfile::new("Smooth orbit", DspSettings::default())); + state.profiles.push(config::DspProfile::new( + "Smooth orbit", + DspSettings::default(), + )); } if state.selected_profile_index >= state.profiles.len() { state.selected_profile_index = 0; @@ -1006,11 +1179,15 @@ fn ensure_state_is_valid(state: &mut SavedState) { } } - - if !state.playback_session.position_seconds.is_finite() || state.playback_session.position_seconds < 0.0 { + if !state.playback_session.position_seconds.is_finite() + || state.playback_session.position_seconds < 0.0 + { state.playback_session.position_seconds = 0.0; } - if !matches!(state.playback_session.source.as_str(), "music" | "track" | "radio") { + if !matches!( + state.playback_session.source.as_str(), + "music" | "track" | "radio" + ) { state.playback_session = PlaybackSession::default(); } if let Some(index) = state.playback_session.radio_index { @@ -1039,7 +1216,6 @@ fn next_valid_track_index(previous_index: usize, remaining_len: usize) -> Option } } - fn paint_sticky_folder_header( ui: &egui::Ui, visible_rect: egui::Rect, @@ -1050,7 +1226,10 @@ fn paint_sticky_folder_header( let header_height = 24.0; let rect = egui::Rect::from_min_max( egui::pos2(visible_rect.left(), visible_rect.top() + push_offset_y), - egui::pos2(visible_rect.right(), visible_rect.top() + push_offset_y + header_height), + egui::pos2( + visible_rect.right(), + visible_rect.top() + push_offset_y + header_height, + ), ); let painter = ui.painter().with_clip_rect(visible_rect); let visuals = ui.visuals(); @@ -1059,14 +1238,23 @@ fn paint_sticky_folder_header( painter.rect_filled(rect, 0.0, background); painter.line_segment([rect.left_bottom(), rect.right_bottom()], stroke); - let icon = if collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; + let icon = if collapsed { + Icon::ChevronRight + } else { + Icon::ChevronDown + }; let icon_rect = egui::Rect::from_min_size( egui::pos2(rect.left() + 8.0, rect.top() + 3.0), egui::vec2(18.0, header_height - 6.0), ); let text_left = icon_rect.right() + 4.0; let text_width = (rect.right() - text_left - 10.0).max(24.0); - let text_color = visuals.widgets.inactive.fg_stroke.color.linear_multiply(0.92); + let text_color = visuals + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.92); painter.text( icon_rect.center(), @@ -1092,7 +1280,15 @@ fn paint_dragged_row_fade(ui: &egui::Ui, rect: egui::Rect) { painter.rect_stroke( rect, 4.0, - egui::Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color.linear_multiply(0.70)), + egui::Stroke::new( + 1.0, + ui.visuals() + .widgets + .inactive + .bg_stroke + .color + .linear_multiply(0.70), + ), egui::StrokeKind::Inside, ); } @@ -1117,7 +1313,11 @@ fn paint_list_separator(ui: &mut egui::Ui, width: f32, highlighted: bool) { } fn paint_list_edge_separator(ui: &egui::Ui, row_rect: egui::Rect, width: f32, after: bool) { - let y = if after { row_rect.bottom() } else { row_rect.top() - 1.0 }; + let y = if after { + row_rect.bottom() + } else { + row_rect.top() - 1.0 + }; paint_list_separator_line(ui, row_rect.left(), row_rect.left() + width, y, true); } @@ -1179,7 +1379,11 @@ fn sample_waveform_column(waveform: &[f32], column: usize, columns: usize) -> f3 .clamp((start + 1) as f32, len as f32) as usize; let slice = &waveform[start..end]; let stride = (slice.len() / 24).max(1); - slice.iter().step_by(stride).copied().fold(0.0_f32, f32::max) + slice + .iter() + .step_by(stride) + .copied() + .fold(0.0_f32, f32::max) } fn draw_waveform_seek( @@ -1200,7 +1404,8 @@ fn draw_waveform_seek( if waveform.is_empty() { if show_loading_wave { paint_waveform_loading_wave(ui, rect); - ui.ctx().request_repaint_after(WAVEFORM_LOADING_REPAINT_INTERVAL); + ui.ctx() + .request_repaint_after(WAVEFORM_LOADING_REPAINT_INTERVAL); } return response; } @@ -1209,7 +1414,8 @@ fn draw_waveform_seek( let progress_x = rect.left() + rect.width() * progress; let column_count = (rect.width() / RADIO_WAVEFORM_BAR_PITCH_PIXELS) .floor() - .max(1.0) as usize + 1; + .max(1.0) as usize + + 1; let mut values = Vec::with_capacity(column_count); for column in 0..column_count { values.push(sample_waveform_column(waveform, column, column_count)); @@ -1232,7 +1438,9 @@ fn draw_waveform_seek( break; } - let height = (rect.height() * 0.84 * eased).max(2.0).min(rect.height() - 4.0); + let height = (rect.height() * 0.84 * eased) + .max(2.0) + .min(rect.height() - 4.0); let bar_start_seconds = if duration_seconds > 0.0 { bar_index as f32 / column_count.max(1) as f32 * duration_seconds } else { @@ -1244,7 +1452,9 @@ fn draw_waveform_seek( 0.0 }; let is_silence = duration_seconds > 0.0 - && silence_ranges.iter().any(|(start, end)| *end > bar_start_seconds && *start < bar_end_seconds); + && silence_ranges + .iter() + .any(|(start, end)| *end > bar_start_seconds && *start < bar_end_seconds); let color = if is_silence { silence_color @@ -1275,7 +1485,6 @@ fn draw_waveform_seek( response } - fn paint_waveform_loading_wave(ui: &egui::Ui, rect: egui::Rect) { let painter = ui.painter(); let time = ui.input(|input| input.time) as f32; @@ -1307,7 +1516,10 @@ fn paint_waveform_loading_wave(ui: &egui::Ui, rect: egui::Rect) { let mut previous: Option = None; for index in 0..points { let t = index as f32 / (points.saturating_sub(1).max(1)) as f32; - let distance = (t - pulse_center).abs().min((t - pulse_center + 1.0).abs()).min((t - pulse_center - 1.0).abs()); + let distance = (t - pulse_center) + .abs() + .min((t - pulse_center + 1.0).abs()) + .min((t - pulse_center - 1.0).abs()); if distance > pulse_half_width { previous = None; continue; @@ -1354,7 +1566,6 @@ fn format_track_metadata_compact(track: &Track) -> String { format!("{duration} · {sample_rate} · {bitrate} · {channels} · {size}") } - fn format_track_metadata_player_only(track: &Track) -> String { track .metadata @@ -1374,7 +1585,11 @@ fn next_row_pointer_hovered(ui: &egui::Ui, width: f32, height: f32) -> bool { }) } -fn rendered_to_original_position(rendered_position: f32, render_start: f32, playback: &PlaybackInfo) -> f32 { +fn rendered_to_original_position( + rendered_position: f32, + render_start: f32, + playback: &PlaybackInfo, +) -> f32 { if playback.silence_ranges.is_empty() { return rendered_position.clamp(0.0, playback.original_duration_seconds.max(0.0)); } @@ -1402,7 +1617,8 @@ fn rendered_to_original_position(rendered_position: f32, render_start: f32, play }) .sum::(); - original = (rendered_position + skipped_before).min(playback.original_duration_seconds.max(0.0)); + original = + (rendered_position + skipped_before).min(playback.original_duration_seconds.max(0.0)); if (original - previous).abs() < 0.001 { break; } @@ -1451,7 +1667,6 @@ fn display_parent(path: &Path) -> String { .unwrap_or_else(|| path.display().to_string()) } - fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { let available_width = ui.available_width().max(260.0); let label_width = available_width.min(170.0); @@ -1463,9 +1678,14 @@ fn detail_row(ui: &mut egui::Ui, label: &str, value: &str) { ui.set_min_width(label_width); ui.set_max_width(label_width); ui.label( - egui::RichText::new(label) - .strong() - .color(ui.visuals().widgets.inactive.fg_stroke.color.linear_multiply(0.72)), + egui::RichText::new(label).strong().color( + ui.visuals() + .widgets + .inactive + .fg_stroke + .color + .linear_multiply(0.72), + ), ); }); ui.vertical(|ui| { @@ -1485,7 +1705,10 @@ fn reveal_in_file_manager(path: &Path) -> anyhow::Result<()> { }; let looks_like_file = target.is_file() || (!target.is_dir() && path.extension().is_some()); let folder = if looks_like_file { - target.parent().map(Path::to_path_buf).unwrap_or_else(|| target.clone()) + target + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| target.clone()) } else { target.clone() }; @@ -1527,4 +1750,3 @@ fn explorer_compatible_path(path: &Path) -> PathBuf { } path.to_path_buf() } - diff --git a/src/media_keys.rs b/src/media_keys.rs index db0e06c..d68ecf9 100644 --- a/src/media_keys.rs +++ b/src/media_keys.rs @@ -66,8 +66,8 @@ fn start_platform_listener() -> MediaKeyListener { #[cfg(windows)] fn run_windows_media_key_loop(sender: mpsc::Sender) { use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - RegisterHotKey, UnregisterHotKey, MOD_NOREPEAT, VK_MEDIA_NEXT_TRACK, - VK_MEDIA_PLAY_PAUSE, VK_MEDIA_PREV_TRACK, VK_MEDIA_STOP, + RegisterHotKey, UnregisterHotKey, MOD_NOREPEAT, VK_MEDIA_NEXT_TRACK, VK_MEDIA_PLAY_PAUSE, + VK_MEDIA_PREV_TRACK, VK_MEDIA_STOP, }; use windows_sys::Win32::UI::WindowsAndMessaging::{GetMessageW, MSG, WM_HOTKEY}; @@ -77,10 +77,22 @@ fn run_windows_media_key_loop(sender: mpsc::Sender) { const HOTKEY_NEXT: i32 = 0x4104; let hotkeys = [ - (HOTKEY_PREVIOUS, VK_MEDIA_PREV_TRACK as u32, MediaKeyCommand::Previous), - (HOTKEY_PLAY_PAUSE, VK_MEDIA_PLAY_PAUSE as u32, MediaKeyCommand::PlayPause), + ( + HOTKEY_PREVIOUS, + VK_MEDIA_PREV_TRACK as u32, + MediaKeyCommand::Previous, + ), + ( + HOTKEY_PLAY_PAUSE, + VK_MEDIA_PLAY_PAUSE as u32, + MediaKeyCommand::PlayPause, + ), (HOTKEY_STOP, VK_MEDIA_STOP as u32, MediaKeyCommand::Stop), - (HOTKEY_NEXT, VK_MEDIA_NEXT_TRACK as u32, MediaKeyCommand::Next), + ( + HOTKEY_NEXT, + VK_MEDIA_NEXT_TRACK as u32, + MediaKeyCommand::Next, + ), ]; let mut registered_ids = Vec::new(); @@ -88,7 +100,8 @@ fn run_windows_media_key_loop(sender: mpsc::Sender) { let mut failed_commands = Vec::new(); for (id, key, command) in hotkeys { - let registered = unsafe { RegisterHotKey(std::ptr::null_mut(), id, MOD_NOREPEAT, key) != 0 }; + let registered = + unsafe { RegisterHotKey(std::ptr::null_mut(), id, MOD_NOREPEAT, key) != 0 }; if registered { registered_ids.push(id); registered_commands.push(command); diff --git a/src/single_instance.rs b/src/single_instance.rs index 2b93f3c..234183e 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -1,5 +1,39 @@ +use std::path::PathBuf; + +#[cfg(windows)] +use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub fn open_request_dir() -> Option { + crate::config::app_data_dir().map(|directory| directory.join("open-requests")) +} + +#[cfg(windows)] +fn queue_open_request(paths: &[PathBuf]) -> Result<(), String> { + if paths.is_empty() { + return Ok(()); + } + + let directory = open_request_dir() + .ok_or_else(|| "failed to resolve Audio Orbit data directory".to_owned())?; + fs::create_dir_all(&directory) + .map_err(|error| format!("failed to create open-request directory: {error}"))?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let path = directory.join(format!("{}-{timestamp}.json", std::process::id())); + let contents = serde_json::to_vec(paths) + .map_err(|error| format!("failed to serialize open request: {error}"))?; + fs::write(&path, contents) + .map_err(|error| format!("failed to queue open request {}: {error}", path.display())) +} + #[cfg(windows)] mod platform { + use super::*; use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE}; use windows_sys::Win32::System::Threading::CreateMutexW; @@ -19,7 +53,7 @@ mod platform { } } - pub fn acquire() -> Result, String> { + pub fn acquire(open_paths: &[PathBuf]) -> Result, String> { let mut name = MUTEX_NAME.encode_utf16().collect::>(); name.push(0); @@ -33,6 +67,7 @@ mod platform { unsafe { CloseHandle(handle); } + queue_open_request(open_paths)?; return Ok(None); } @@ -42,9 +77,11 @@ mod platform { #[cfg(not(windows))] mod platform { + use super::*; + pub struct SingleInstanceGuard; - pub fn acquire() -> Result, String> { + pub fn acquire(_open_paths: &[PathBuf]) -> Result, String> { Ok(Some(SingleInstanceGuard)) } } diff --git a/src/spectrum_waveform.rs b/src/spectrum_waveform.rs index 48d1ea8..a0ab9c4 100644 --- a/src/spectrum_waveform.rs +++ b/src/spectrum_waveform.rs @@ -16,7 +16,6 @@ pub struct SpectrumBucket { pub high: f32, } - /// Build an AIMP-style overview waveform. /// /// The visible amplitude is shaped mostly from RMS/loudness with a restrained peak @@ -68,7 +67,10 @@ pub struct LiveSpectrumAnalyzer { impl LiveSpectrumAnalyzer { pub fn new(sample_rate: u32, buckets_per_second: usize) -> Self { let sample_rate = sample_rate.max(1); - let fft_size = LIVE_FFT_SIZE.min(sample_rate as usize).max(512).next_power_of_two(); + let fft_size = LIVE_FFT_SIZE + .min(sample_rate as usize) + .max(512) + .next_power_of_two(); let hop_size = (sample_rate as usize / buckets_per_second.max(1)).max(1); Self { fft_size, @@ -306,10 +308,26 @@ impl SpectrumAnalyzer { } } - let average = if weight_sum > 0.0 { weighted_sum / weight_sum } else { 0.0 }; - let low = if low_weight > 0.0 { low_sum / low_weight } else { 0.0 }; - let mid = if mid_weight > 0.0 { mid_sum / mid_weight } else { 0.0 }; - let high = if high_weight > 0.0 { high_sum / high_weight } else { 0.0 }; + let average = if weight_sum > 0.0 { + weighted_sum / weight_sum + } else { + 0.0 + }; + let low = if low_weight > 0.0 { + low_sum / low_weight + } else { + 0.0 + }; + let mid = if mid_weight > 0.0 { + mid_sum / mid_weight + } else { + 0.0 + }; + let high = if high_weight > 0.0 { + high_sum / high_weight + } else { + 0.0 + }; let mut positive_flux = 0.0_f32; let mut flux_count = 0usize; diff --git a/src/time_stretch.rs b/src/time_stretch.rs new file mode 100644 index 0000000..98c33bb --- /dev/null +++ b/src/time_stretch.rs @@ -0,0 +1,240 @@ +const DEFAULT_WINDOW_FRAMES: usize = 2_048; +const MIN_WINDOW_FRAMES: usize = 64; +const MAX_SEARCH_RADIUS_FRAMES: usize = 512; +const CORRELATION_STRIDE: usize = 16; +const CANDIDATE_STRIDE: usize = 8; + +/// Changes stereo duration while preserving local waveform pitch with deterministic WSOLA. +/// +/// Smart DJ limits tempo changes to ±6%, where short-window waveform matching produces +/// stable results without native libraries or generated bindings. +pub(crate) fn pitch_preserving_stretch(input: &[[f32; 2]], speed_ratio: f32) -> Vec<[f32; 2]> { + if input.is_empty() { + return Vec::new(); + } + if !speed_ratio.is_finite() || speed_ratio <= 0.0 { + return input.to_vec(); + } + if (speed_ratio - 1.0).abs() < 0.0005 { + return input.to_vec(); + } + + let target_frames = ((input.len() as f64 / speed_ratio as f64).round() as usize).max(1); + if input.len() < MIN_WINDOW_FRAMES || target_frames < MIN_WINDOW_FRAMES { + return resample_linear(input, target_frames); + } + + let window_frames = DEFAULT_WINDOW_FRAMES + .min(input.len()) + .min(target_frames) + .max(MIN_WINDOW_FRAMES); + let overlap_frames = (window_frames / 2).max(1); + let synthesis_hop = window_frames - overlap_frames; + let analysis_hop = synthesis_hop as f64 * speed_ratio as f64; + let search_radius = (window_frames / 4).min(MAX_SEARCH_RADIUS_FRAMES); + let max_input_start = input.len().saturating_sub(window_frames); + + let mut output = vec![[0.0; 2]; target_frames]; + let initial_frames = window_frames.min(target_frames).min(input.len()); + output[..initial_frames].copy_from_slice(&input[..initial_frames]); + + let mut synthesis_position = synthesis_hop; + let mut previous_input_position = 0usize; + + while synthesis_position < target_frames { + let expected_input_position = (previous_input_position as f64 + analysis_hop) + .round() + .clamp(0.0, max_input_start as f64) as usize; + let search_start = expected_input_position.saturating_sub(search_radius); + let search_end = expected_input_position + .saturating_add(search_radius) + .min(max_input_start); + let overlap_length = overlap_frames + .min(target_frames - synthesis_position) + .min(window_frames); + + let best_input_position = best_matching_position( + &output, + synthesis_position, + input, + search_start, + search_end, + expected_input_position, + overlap_length, + ); + let segment_length = window_frames.min(target_frames - synthesis_position); + let blend_length = overlap_length.min(segment_length); + + for offset in 0..blend_length { + let phase = (offset + 1) as f32 / (blend_length + 1) as f32; + let fade_in = 0.5 - 0.5 * (std::f32::consts::PI * phase).cos(); + let fade_out = 1.0 - fade_in; + let existing = output[synthesis_position + offset]; + let incoming = input[best_input_position + offset]; + output[synthesis_position + offset] = [ + existing[0] * fade_out + incoming[0] * fade_in, + existing[1] * fade_out + incoming[1] * fade_in, + ]; + } + + if segment_length > blend_length { + let output_start = synthesis_position + blend_length; + let output_end = synthesis_position + segment_length; + let input_start = best_input_position + blend_length; + let input_end = best_input_position + segment_length; + output[output_start..output_end].copy_from_slice(&input[input_start..input_end]); + } + + previous_input_position = best_input_position; + synthesis_position = synthesis_position.saturating_add(synthesis_hop); + } + + output +} + +fn best_matching_position( + output: &[[f32; 2]], + output_position: usize, + input: &[[f32; 2]], + search_start: usize, + search_end: usize, + expected_position: usize, + overlap_length: usize, +) -> usize { + if search_start >= search_end || overlap_length == 0 { + return search_start; + } + + let mut best_position = expected_position.clamp(search_start, search_end); + let mut best_score = f64::NEG_INFINITY; + let mut best_distance = usize::MAX; + + let mut candidate = search_start; + loop { + let score = similarity_score(output, output_position, input, candidate, overlap_length); + let distance = candidate.abs_diff(expected_position); + if score > best_score + f64::EPSILON + || ((score - best_score).abs() <= f64::EPSILON && distance < best_distance) + { + best_score = score; + best_position = candidate; + best_distance = distance; + } + + if candidate >= search_end { + break; + } + candidate = candidate.saturating_add(CANDIDATE_STRIDE).min(search_end); + } + + let refine_start = best_position.saturating_sub(CANDIDATE_STRIDE); + let refine_end = best_position + .saturating_add(CANDIDATE_STRIDE) + .min(search_end); + for candidate in refine_start..=refine_end { + if candidate < search_start { + continue; + } + let score = similarity_score(output, output_position, input, candidate, overlap_length); + let distance = candidate.abs_diff(expected_position); + if score > best_score + f64::EPSILON + || ((score - best_score).abs() <= f64::EPSILON && distance < best_distance) + { + best_score = score; + best_position = candidate; + best_distance = distance; + } + } + + best_position +} + +fn similarity_score( + output: &[[f32; 2]], + output_position: usize, + input: &[[f32; 2]], + input_position: usize, + overlap_length: usize, +) -> f64 { + let mut dot = 0.0f64; + let mut output_energy = 0.0f64; + let mut input_energy = 0.0f64; + + for offset in (0..overlap_length).step_by(CORRELATION_STRIDE) { + let existing = output[output_position + offset]; + let candidate = input[input_position + offset]; + for channel in 0..2 { + let left = existing[channel] as f64; + let right = candidate[channel] as f64; + dot += left * right; + output_energy += left * left; + input_energy += right * right; + } + } + + const SILENCE_ENERGY: f64 = 1.0e-12; + if output_energy <= SILENCE_ENERGY && input_energy <= SILENCE_ENERGY { + return 0.0; + } + if output_energy <= SILENCE_ENERGY || input_energy <= SILENCE_ENERGY { + return -1.0; + } + + dot / (output_energy * input_energy).sqrt() +} + +fn resample_linear(input: &[[f32; 2]], target_frames: usize) -> Vec<[f32; 2]> { + if target_frames == 0 || input.is_empty() { + return Vec::new(); + } + if target_frames == 1 || input.len() == 1 { + return vec![input[0]; target_frames]; + } + + let scale = (input.len() - 1) as f64 / (target_frames - 1) as f64; + (0..target_frames) + .map(|index| { + let source_position = index as f64 * scale; + let left_index = source_position.floor() as usize; + let right_index = (left_index + 1).min(input.len() - 1); + let fraction = (source_position - left_index as f64) as f32; + [ + input[left_index][0] * (1.0 - fraction) + input[right_index][0] * fraction, + input[left_index][1] * (1.0 - fraction) + input[right_index][1] * fraction, + ] + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::pitch_preserving_stretch; + + #[test] + fn keeps_unity_ratio_unchanged() { + let input = vec![[0.25, -0.25]; 512]; + assert_eq!(pitch_preserving_stretch(&input, 1.0), input); + } + + #[test] + fn produces_expected_duration() { + let input = (0..44_100) + .map(|index| { + let phase = index as f32 * 0.01; + [phase.sin() * 0.5, phase.cos() * 0.5] + }) + .collect::>(); + let output = pitch_preserving_stretch(&input, 1.05); + assert_eq!(output.len(), (input.len() as f64 / 1.05).round() as usize); + assert!(output.iter().flatten().all(|sample| sample.is_finite())); + } + + #[test] + fn preserves_constant_stereo_signal() { + let input = vec![[0.25, -0.5]; 8_192]; + let output = pitch_preserving_stretch(&input, 0.95); + assert!(output + .iter() + .all(|frame| (frame[0] - 0.25).abs() < 1.0e-6 && (frame[1] + 0.5).abs() < 1.0e-6)); + } +} diff --git a/src/updater.rs b/src/updater.rs index bf632c6..5833989 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -3,14 +3,14 @@ use reqwest::{blocking::Client, StatusCode}; use semver::Version; use serde::{de::DeserializeOwned, Deserialize}; use std::{ - env, - fs, + env, fs, path::{Path, PathBuf}, process::Command, }; const RELEASES_API: &str = "https://api.github.com/repos/rozsazoltan/audio-orbit/releases"; -const LATEST_RELEASE_API: &str = "https://api.github.com/repos/rozsazoltan/audio-orbit/releases/latest"; +const LATEST_RELEASE_API: &str = + "https://api.github.com/repos/rozsazoltan/audio-orbit/releases/latest"; const RELEASES_PAGE: &str = "https://github.com/rozsazoltan/audio-orbit/releases"; const USER_AGENT: &str = "Audio-Orbit-Updater"; @@ -48,7 +48,8 @@ pub fn check_for_update(include_prereleases: bool) -> Result { pub fn check_latest_stable() -> Result { let client = Client::builder().user_agent(USER_AGENT).build()?; - let release: GitHubRelease = get_github_json(&client, LATEST_RELEASE_API, "GitHub latest stable release")?; + let release: GitHubRelease = + get_github_json(&client, LATEST_RELEASE_API, "GitHub latest stable release")?; update_check_from_release(release) } @@ -74,14 +75,20 @@ pub fn check_latest_prerelease() -> Result { fn update_check_from_release(release: GitHubRelease) -> Result { let current_version = env!("CARGO_PKG_VERSION").to_owned(); - let current_semver = Version::parse(¤t_version).context("invalid current application version")?; + let current_semver = + Version::parse(¤t_version).context("invalid current application version")?; let latest_semver = Version::parse(release.tag_name.trim_start_matches('v')) .context("invalid latest GitHub release version")?; let asset = release .assets .iter() .find(|asset| asset.name.ends_with("windows-x64.exe")) - .or_else(|| release.assets.iter().find(|asset| asset.name.ends_with(".exe"))); + .or_else(|| { + release + .assets + .iter() + .find(|asset| asset.name.ends_with(".exe")) + }); Ok(UpdateCheck { current_version,