diff --git a/.gitignore b/.gitignore index bac5fc1..7648543 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ target /ce /assets/music +# BPM accuracy test audio files (downloaded via scripts/download_test_audio.sh) +/crates/dj/tests/fixtures/audio/*.mp3 +/crates/dj/tests/fixtures/audio/*.wav +/crates/dj/tests/fixtures/giantsteps-tempo-dataset/ + diff --git a/Cargo.lock b/Cargo.lock index 5696681..f4e1824 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.18" @@ -479,6 +488,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-task" version = "4.7.1" @@ -552,12 +583,85 @@ dependencies = [ "zbus", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "aubio-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9189fd9d8af82083a442e8b73ce12e602a6719f7360cf53f0bb9cb9e8c8d5a87" +dependencies = [ + "aubio-sys", +] + +[[package]] +name = "aubio-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99ef2dfeaceccd0b8a6d72203409acc927d9eebc8180c5756099549c9f8f20a8" +dependencies = [ + "bindgen 0.58.1", + "cc", +] + [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "bindgen" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8523b410d7187a43085e7e064416ea32ded16bd0a4e6fc025e21616d01258f" +dependencies = [ + "bitflags 1.3.2", + "cexpr 0.4.0", + "clang-sys", + "clap 2.34.0", + "env_logger 0.8.4", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "which", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.4", + "cexpr 0.6.0", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -565,7 +669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.9.4", - "cexpr", + "cexpr 0.6.0", "clang-sys", "itertools", "log", @@ -729,13 +833,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27" +dependencies = [ + "nom 5.1.3", +] + [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -768,6 +881,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.1", ] @@ -783,6 +897,21 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.5.53" @@ -802,7 +931,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -1383,6 +1512,42 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "epaint" version = "0.33.3" @@ -1456,6 +1621,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1792,11 +1969,13 @@ version = "0.1.0" dependencies = [ "anyhow", "artnet_protocol", - "clap", + "clap 4.5.53", "crossterm", "eframe", "halo-core", + "halo-dj", "halo-fixtures", + "halo-push2", "halo-ui", "log", "midir", @@ -1831,6 +2010,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "halo-dj" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "aubio-rs", + "cc", + "chrono", + "cpal 0.17.0", + "dirs", + "env_logger 0.11.8", + "halo-core", + "halo-fixtures", + "log", + "midir", + "parking_lot", + "rodio", + "rusqlite", + "rustfft", + "serde", + "serde_json", + "soundtouch", + "symphonia", + "tempfile", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "halo-fixtures" version = "0.1.0" @@ -1839,6 +2047,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "halo-push2" +version = "0.1.0" +dependencies = [ + "async-trait", + "halo-core", + "halo-dj", + "midir", + "rusb", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tracing", +] + [[package]] name = "halo-ui" version = "0.1.0" @@ -1848,12 +2071,22 @@ dependencies = [ "egui_plot", "halo-core", "halo-fixtures", + "log", "parking_lot", "rand", "rfd", "tokio", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.3" @@ -1872,12 +2105,30 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.4.0" @@ -1896,6 +2147,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -2103,6 +2360,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -2174,6 +2455,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.172" @@ -2207,6 +2494,29 @@ dependencies = [ "redox_syscall 0.5.11", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2432,6 +2742,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -2452,6 +2772,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -2931,6 +3260,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3007,7 +3342,7 @@ checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.4.0", "pin-project-lite", "rustix 0.38.44", "tracing", @@ -3060,6 +3395,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3259,6 +3603,30 @@ dependencies = [ "symphonia", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.9.4", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3271,6 +3639,20 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3309,7 +3691,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "211094a41b8b46baa0d356c8a09b742fc9b55ce6dfa25c89b6ce6abf814a87f2" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cmake", ] @@ -3528,6 +3910,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "soundtouch" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132ff7c331f49bdd5c02b79a287fcc583260d9b7e8365a0b05b836f9b734d4ba" +dependencies = [ + "soundtouch-ffi", +] + +[[package]] +name = "soundtouch-ffi" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a157b7c8482ea7218ff1cfbff913b26bad36ae0bdd06255146e22e78116a16" +dependencies = [ + "bindgen 0.71.1", + "cc", +] + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -3549,12 +3950,24 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -3777,6 +4190,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3891,6 +4313,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "toml_datetime" version = "0.6.9" @@ -3939,6 +4385,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4019,6 +4475,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" @@ -4423,6 +4891,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index dd4ce8f..12622a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,35 @@ [workspace] -members = ["crates/core", "crates/fixtures", "crates/halo", "crates/ui"] +members = ["crates/core", "crates/dj", "crates/fixtures", "crates/halo", "crates/push2", "crates/ui"] default-members = ["crates/halo"] resolver = "2" + +# Optimize heavy computational crates even in dev mode for fast analysis +[profile.dev.package.halo-dj] +opt-level = 3 + +[profile.dev.package.rustfft] +opt-level = 3 + +[profile.dev.package.aubio-rs] +opt-level = 3 + +[profile.dev.package.symphonia] +opt-level = 3 + +[profile.dev.package.symphonia-core] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-mp3] +opt-level = 3 + +[profile.dev.package.symphonia-bundle-flac] +opt-level = 3 + +[profile.dev.package.symphonia-format-wav] +opt-level = 3 + +[profile.dev.package.symphonia-codec-aac] +opt-level = 3 + +[profile.dev.package.rubato] +opt-level = 3 diff --git a/config.json b/config.json index 7146a83..c9b0031 100644 --- a/config.json +++ b/config.json @@ -20,7 +20,8 @@ "pixel_engine_enabled": true, "pixel_engine_fps": 44.0, "pixel_universe_mapping": {}, - "enable_pan_tilt_limits": true + "enable_pan_tilt_limits": true, + "push2_enabled": true }, "created_at": "2025-10-13T02:53:54.682044+00:00", "modified_at": "2025-10-13T02:53:54.682105+00:00" diff --git a/crates/core/src/console.rs b/crates/core/src/console.rs index 8adb8f9..c2b3f50 100644 --- a/crates/core/src/console.rs +++ b/crates/core/src/console.rs @@ -111,6 +111,8 @@ impl LightingConsole { bars_per_phrase: 4, last_tap_time: None, tap_count: 0, + bpm: 120.0, + tempo_source: crate::rhythm::rhythm::TempoSource::Internal, })), link_manager: Arc::new(Mutex::new(AbletonLinkManager::new())), settings: Arc::new(RwLock::new(settings)), @@ -122,6 +124,12 @@ impl LightingConsole { }) } + /// Register an additional module with the console. + /// Must be called before `initialize()`. + pub fn register_module(&mut self, module: Box) { + self.module_manager.register_module(module); + } + /// Initialize the async console and all modules pub async fn initialize(&mut self) -> Result<(), anyhow::Error> { log::info!("Initializing async lighting console..."); @@ -1625,6 +1633,8 @@ impl LightingConsole { bars_per_phrase: rhythm_guard.bars_per_phrase, last_tap_time: rhythm_guard.last_tap_time, tap_count: rhythm_guard.tap_count, + bpm: rhythm_guard.bpm, + tempo_source: rhythm_guard.tempo_source, }; let _ = event_tx.send(ConsoleEvent::CurrentRhythmState { state }); } @@ -1663,6 +1673,421 @@ impl LightingConsole { let num_peers = self.get_ableton_link_peers().await; let _ = event_tx.send(ConsoleEvent::LinkStateChanged { enabled, num_peers }); } + ToggleAbletonLink => { + let currently_enabled = self.is_ableton_link_enabled().await; + if currently_enabled { + self.disable_ableton_link().await; + } else if let Err(e) = self.enable_ableton_link().await { + let _ = event_tx.send(ConsoleEvent::Error { + message: format!("Failed to enable Ableton Link: {}", e), + }); + } + let enabled = self.is_ableton_link_enabled().await; + let num_peers = self.get_ableton_link_peers().await; + let _ = event_tx.send(ConsoleEvent::LinkStateChanged { enabled, num_peers }); + } + + SetTempoSource { source } => { + log::info!("Setting tempo source to: {:?}", source); + let mut rhythm_state = self.rhythm_state.write().await; + rhythm_state.tempo_source = source; + // Notify UI of the rhythm state change + let state = crate::RhythmState { + beat_phase: rhythm_state.beat_phase, + bar_phase: rhythm_state.bar_phase, + phrase_phase: rhythm_state.phrase_phase, + beats_per_bar: rhythm_state.beats_per_bar, + bars_per_phrase: rhythm_state.bars_per_phrase, + last_tap_time: rhythm_state.last_tap_time, + tap_count: rhythm_state.tap_count, + bpm: rhythm_state.bpm, + tempo_source: rhythm_state.tempo_source, + }; + let _ = event_tx.send(ConsoleEvent::RhythmStateUpdated { state }); + } + + // DJ commands - forward to DJ module + DjImportFolder { path } => { + log::info!("DJ: Importing folder: {:?}", path); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjImportFolder { path }, + ), + ) + .await; + } + DjLoadTrack { deck, track_id } => { + eprintln!( + "DEBUG: Processing DjLoadTrack - deck={}, track_id={}", + deck, track_id + ); + log::info!("DJ: Loading track {} to deck {}", track_id, deck); + let result = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjLoadTrack { deck, track_id }, + ), + ) + .await; + eprintln!("DEBUG: send_to_module result: {:?}", result); + } + DjPlay { deck } => { + log::info!("DJ: Play deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand(ConsoleCommand::DjPlay { + deck, + }), + ) + .await; + } + DjPause { deck } => { + log::info!("DJ: Pause deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand(ConsoleCommand::DjPause { + deck, + }), + ) + .await; + } + DjStop { deck } => { + log::info!("DJ: Stop deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand(ConsoleCommand::DjStop { + deck, + }), + ) + .await; + } + DjSetCue { deck } => { + log::info!("DJ: Set cue on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand(ConsoleCommand::DjSetCue { + deck, + }), + ) + .await; + } + DjJumpToCue { deck } => { + log::info!("DJ: Jump to cue on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjJumpToCue { deck }, + ), + ) + .await; + } + DjCuePreview { deck, pressed } => { + log::debug!("DJ: Cue preview deck {} pressed={}", deck, pressed); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjCuePreview { deck, pressed }, + ), + ) + .await; + } + DjSetHotCue { deck, slot } => { + log::info!("DJ: Set hot cue {} on deck {}", slot, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetHotCue { deck, slot }, + ), + ) + .await; + } + DjJumpToHotCue { deck, slot } => { + log::info!("DJ: Jump to hot cue {} on deck {}", slot, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjJumpToHotCue { deck, slot }, + ), + ) + .await; + } + DjSetPitch { deck, percent } => { + log::info!("DJ: Set pitch to {}% on deck {}", percent, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetPitch { deck, percent }, + ), + ) + .await; + } + DjToggleSync { deck } => { + log::info!("DJ: Toggle sync on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjToggleSync { deck }, + ), + ) + .await; + } + DjSetMaster { deck } => { + log::info!("DJ: Set deck {} as master", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetMaster { deck }, + ), + ) + .await; + } + DjSeek { + deck, + position_seconds, + } => { + log::info!("DJ: Seek to {}s on deck {}", position_seconds, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand(ConsoleCommand::DjSeek { + deck, + position_seconds, + }), + ) + .await; + } + DjSeekBeats { deck, beats } => { + log::info!("DJ: Seek {} beats on deck {}", beats, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSeekBeats { deck, beats }, + ), + ) + .await; + } + DjNudgePitch { deck, delta } => { + log::debug!("DJ: Nudge pitch by {} on deck {}", delta, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjNudgePitch { deck, delta }, + ), + ) + .await; + } + DjPreviousTrack { deck } => { + log::debug!("DJ: Previous track on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjPreviousTrack { deck }, + ), + ) + .await; + } + DjNextTrack { deck } => { + log::debug!("DJ: Next track on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjNextTrack { deck }, + ), + ) + .await; + } + DjQueryLibrary => { + log::debug!("DJ: Querying library"); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjQueryLibrary, + ), + ) + .await; + } + DjToggleMasterTempo { deck } => { + log::debug!("DJ: Toggle Master Tempo on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjToggleMasterTempo { deck }, + ), + ) + .await; + } + DjSetTempoRange { deck, range } => { + log::debug!("DJ: Set tempo range {} on deck {}", range, deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetTempoRange { deck, range }, + ), + ) + .await; + } + DjSetLoop { deck, beat_count } => { + log::debug!("DJ: Set {}-beat loop on deck {}", beat_count, deck); + let _ = + self.module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetLoop { deck, beat_count }, + ), + ) + .await; + } + DjToggleLoop { deck } => { + log::debug!("DJ: Toggle loop on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjToggleLoop { deck }, + ), + ) + .await; + } + DjHalveLoop { deck } => { + log::debug!("DJ: Halve loop on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjHalveLoop { deck }, + ), + ) + .await; + } + DjDoubleLoop { deck } => { + log::debug!("DJ: Double loop on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjDoubleLoop { deck }, + ), + ) + .await; + } + DjReanalyzeTrack { track_id } => { + log::info!("DJ: Re-analyzing track {}", track_id); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjReanalyzeTrack { track_id }, + ), + ) + .await; + } + DjUpdateTrackBpm { track_id, bpm } => { + log::info!("DJ: Updating track {} BPM to {}", track_id, bpm); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjUpdateTrackBpm { track_id, bpm }, + ), + ) + .await; + } + DjDeleteTrack { track_id } => { + log::info!("DJ: Deleting track {}", track_id); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjDeleteTrack { track_id }, + ), + ) + .await; + } + DjNudgeBeatGrid { deck, offset_ms } => { + log::debug!("DJ: Nudging beat grid on deck {} by {}ms", deck, offset_ms); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjNudgeBeatGrid { deck, offset_ms }, + ), + ) + .await; + } + DjSetDownbeat { deck } => { + log::debug!("DJ: Setting downbeat on deck {}", deck); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjSetDownbeat { deck }, + ), + ) + .await; + } + DjShiftBeatGrid { deck, beats } => { + log::debug!("DJ: Shifting beat grid on deck {} by {} beats", deck, beats); + let _ = self + .module_manager + .send_to_module( + crate::modules::traits::ModuleId::Dj, + crate::modules::traits::ModuleEvent::DjCommand( + ConsoleCommand::DjShiftBeatGrid { deck, beats }, + ), + ) + .await; + } // Settings management UpdateSettings { settings } => { @@ -1731,17 +2156,20 @@ impl LightingConsole { mut command_rx: mpsc::UnboundedReceiver, event_tx: mpsc::UnboundedSender, ) -> Result<(), anyhow::Error> { + eprintln!("DEBUG: run_with_channels starting..."); log::info!("Console run_with_channels starting..."); // Start the update loop let mut update_interval = tokio::time::interval(std::time::Duration::from_millis(23)); // ~44Hz + eprintln!("DEBUG: Starting console main loop..."); log::info!("Starting console main loop..."); loop { tokio::select! { // Process commands from UI Some(command) = command_rx.recv() => { - log::debug!("Received command: {:?}", command); + eprintln!("DEBUG: Console received command: {:?}", command); + log::info!("Console received command: {:?}", command); if let ConsoleCommand::Shutdown = command { log::info!("Received shutdown command"); @@ -1791,6 +2219,8 @@ impl LightingConsole { bars_per_phrase: rhythm_guard.bars_per_phrase, last_tap_time: rhythm_guard.last_tap_time, tap_count: rhythm_guard.tap_count, + bpm: rhythm_guard.bpm, + tempo_source: rhythm_guard.tempo_source, }; let _ = event_tx.send(ConsoleEvent::RhythmStateUpdated { state: rhythm_state }); @@ -1815,6 +2245,168 @@ impl LightingConsole { ModuleEvent::MidiInput(midi_msg) => { Self::handle_midi_input(midi_msg, &self.rhythm_state, &self.cue_manager).await; } + ModuleEvent::DjRhythmSync { bpm, beat_phase, bar_phase, phrase_phase } => { + // Update rhythm state from DJ master deck when using DJ tempo source + let mut rhythm_state = self.rhythm_state.write().await; + if rhythm_state.tempo_source == crate::rhythm::rhythm::TempoSource::DjMaster { + rhythm_state.bpm = bpm; + rhythm_state.beat_phase = beat_phase; + rhythm_state.bar_phase = bar_phase; + rhythm_state.phrase_phase = phrase_phase; + } + drop(rhythm_state); + // Broadcast to other modules (e.g., Push 2) - non-blocking for high-frequency updates + self.module_manager.try_broadcast_event(ModuleEvent::DjRhythmSync { + bpm, + beat_phase, + bar_phase, + phrase_phase, + }); + } + ModuleEvent::DjBeat { deck, beat_number, is_downbeat } => { + // Log DJ beat events for debugging + log::trace!( + "DJ Beat: deck={}, beat={}, downbeat={}", + deck, beat_number, is_downbeat + ); + } + ModuleEvent::DjLibraryTracks(tracks) => { + log::debug!("Received {} tracks from DJ module", tracks.len()); + let _ = event_tx.send(ConsoleEvent::DjLibraryTracks { tracks }); + } + ModuleEvent::DjDeckLoaded { deck, track_id, title, artist, duration_seconds, bpm } => { + log::info!("DJ deck {} loaded: {} - {}", deck, artist.as_deref().unwrap_or("Unknown"), title); + // Send to UI + let _ = event_tx.send(ConsoleEvent::DjTrackLoaded { + deck, + track_id, + title: title.clone(), + artist: artist.clone(), + duration_seconds, + bpm, + }); + // Broadcast to other modules (e.g., Push 2) + self.module_manager.broadcast_event(ModuleEvent::DjDeckLoaded { + deck, + track_id, + title, + artist, + duration_seconds, + bpm, + }).await; + } + ModuleEvent::DjDeckStateChanged { deck, is_playing, position_seconds, bpm } => { + // Send to UI + let _ = event_tx.send(ConsoleEvent::DjDeckStateChanged { + deck, + is_playing, + position_seconds, + bpm, + }); + // Broadcast to other modules (e.g., Push 2) - non-blocking for high-frequency updates + self.module_manager.try_broadcast_event(ModuleEvent::DjDeckStateChanged { + deck, + is_playing, + position_seconds, + bpm, + }); + } + ModuleEvent::DjCuePointSet { deck, position_seconds } => { + let _ = event_tx.send(ConsoleEvent::DjCuePointSet { + deck, + position_seconds, + }); + } + ModuleEvent::DjWaveformProgress { deck, samples, progress, frequency_bands } => { + let _ = event_tx.send(ConsoleEvent::DjWaveformProgress { + deck, + samples, + progress, + frequency_bands, + }); + } + ModuleEvent::DjWaveformLoaded { deck, samples, duration_seconds, frequency_bands } => { + // Send to UI + let _ = event_tx.send(ConsoleEvent::DjWaveformLoaded { + deck, + samples: samples.clone(), + duration_seconds, + frequency_bands: frequency_bands.clone(), + }); + // Broadcast to other modules (e.g., Push 2) + self.module_manager.broadcast_event(ModuleEvent::DjWaveformLoaded { + deck, + samples, + duration_seconds, + frequency_bands, + }).await; + } + ModuleEvent::DjBeatGridLoaded { deck, beat_positions, first_beat_offset, bpm, is_nudge } => { + let _ = event_tx.send(ConsoleEvent::DjBeatGridLoaded { + deck, + beat_positions, + first_beat_offset, + bpm, + is_nudge, + }); + } + ModuleEvent::DjMasterTempoChanged { deck, enabled } => { + let _ = event_tx.send(ConsoleEvent::DjMasterTempoChanged { + deck, + enabled, + }); + } + ModuleEvent::DjTempoRangeChanged { deck, range } => { + let _ = event_tx.send(ConsoleEvent::DjTempoRangeChanged { + deck, + range, + }); + } + ModuleEvent::DjPitchChanged { deck, pitch_percent, tempo_range, adjusted_bpm } => { + let _ = event_tx.send(ConsoleEvent::DjPitchChanged { + deck, + pitch_percent, + tempo_range, + adjusted_bpm, + }); + } + ModuleEvent::DjLoopStateChanged { deck, loop_in, loop_out, active, beat_count } => { + let _ = event_tx.send(ConsoleEvent::DjLoopStateChanged { + deck, + loop_in, + loop_out, + active, + beat_count, + }); + } + ModuleEvent::DjAnalysisProgress { track_id, track_name, current, total, progress } => { + let _ = event_tx.send(ConsoleEvent::DjAnalysisProgress { + track_id, + track_name, + current, + total, + progress, + }); + } + ModuleEvent::DjAnalysisComplete { track_id, bpm } => { + let _ = event_tx.send(ConsoleEvent::DjAnalysisComplete { + track_id, + bpm, + }); + } + ModuleEvent::StatusClear => { + let _ = event_tx.send(ConsoleEvent::StatusClear); + } + ModuleEvent::DjCommand(command) => { + // Handle commands from Push 2 or other modules + log::debug!("Processing DjCommand from module: {:?}", command); + if let Err(e) = self.process_command(command, &event_tx).await { + log::error!("Module command processing error: {}", e); + let _ = event_tx.send(ConsoleEvent::Error { + message: format!("Module command error: {}", e) + }); + } + } _ => { // Handle other inter-module events as needed } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c7faca5..514899b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,7 +13,7 @@ pub use effect::effect::{ sawtooth_effect, sine_effect, square_effect, Effect, EffectParams, EffectType, }; pub use effect::EffectRelease; -pub use messages::{ConsoleCommand, ConsoleEvent, Settings}; +pub use messages::{ConsoleCommand, ConsoleEvent, DjTrackInfo, Settings}; pub use midi::midi::{MidiAction, MidiMessage, MidiOverride}; // Async module system exports pub use modules::{ @@ -21,7 +21,7 @@ pub use modules::{ ModuleMessage, SmpteModule, }; pub use pixel::{PixelEffect, PixelEffectParams, PixelEffectScope, PixelEffectType, PixelEngine}; -pub use rhythm::rhythm::{Interval, RhythmState}; +pub use rhythm::rhythm::{Interval, RhythmState, TempoSource}; pub use show::show::Show; pub use show::show_manager::ShowManager; pub use timecode::timecode::TimeCode; diff --git a/crates/core/src/messages.rs b/crates/core/src/messages.rs index 5a7ea6d..39c8eba 100644 --- a/crates/core/src/messages.rs +++ b/crates/core/src/messages.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::Arc; use halo_fixtures::Fixture; use serde::{Deserialize, Serialize}; @@ -6,6 +7,17 @@ use serde::{Deserialize, Serialize}; use crate::audio::device_enumerator::AudioDeviceInfo; use crate::{CueList, EffectType, MidiOverride, PlaybackState, RhythmState, Show, TimeCode}; +/// Track information for UI display. +#[derive(Debug, Clone)] +pub struct DjTrackInfo { + pub id: i64, + pub title: String, + pub artist: Option, + pub duration_seconds: f64, + pub bpm: Option, + pub file_path: String, +} + /// Commands sent from UI to Console #[derive(Debug, Clone)] pub enum ConsoleCommand { @@ -161,6 +173,120 @@ pub enum ConsoleCommand { EnableAbletonLink, DisableAbletonLink, + // Tempo source + SetTempoSource { + source: crate::rhythm::rhythm::TempoSource, + }, + + // DJ commands + DjImportFolder { + path: PathBuf, + }, + DjLoadTrack { + deck: u8, + track_id: i64, + }, + DjPlay { + deck: u8, + }, + DjPause { + deck: u8, + }, + DjStop { + deck: u8, + }, + DjSetCue { + deck: u8, + }, + DjJumpToCue { + deck: u8, + }, + DjCuePreview { + deck: u8, + pressed: bool, + }, + DjSetHotCue { + deck: u8, + slot: u8, + }, + DjJumpToHotCue { + deck: u8, + slot: u8, + }, + DjSetPitch { + deck: u8, + percent: f64, + }, + DjToggleSync { + deck: u8, + }, + DjSetMaster { + deck: u8, + }, + DjSeek { + deck: u8, + position_seconds: f64, + }, + DjSeekBeats { + deck: u8, + beats: i32, + }, + DjNudgePitch { + deck: u8, + delta: f64, + }, + DjPreviousTrack { + deck: u8, + }, + DjNextTrack { + deck: u8, + }, + DjQueryLibrary, + DjToggleMasterTempo { + deck: u8, + }, + DjSetTempoRange { + deck: u8, + range: u8, // 0=±6%, 1=±10%, 2=±16%, 3=±25%, 4=±50% + }, + DjSetLoop { + deck: u8, + beat_count: u8, // 4 or 8 beats + }, + DjToggleLoop { + deck: u8, + }, + DjHalveLoop { + deck: u8, + }, + DjDoubleLoop { + deck: u8, + }, + DjReanalyzeTrack { + track_id: i64, + }, + DjUpdateTrackBpm { + track_id: i64, + bpm: f64, + }, + DjDeleteTrack { + track_id: i64, + }, + DjNudgeBeatGrid { + deck: u8, + offset_ms: f64, + }, + DjSetDownbeat { + deck: u8, + }, + DjShiftBeatGrid { + deck: u8, + beats: i32, + }, + + // Ableton Link toggle + ToggleAbletonLink, + // Effects ApplyEffect { fixture_ids: Vec, @@ -281,6 +407,9 @@ pub struct Settings { // Fixture settings pub enable_pan_tilt_limits: bool, + + // Push 2 settings + pub push2_enabled: bool, } impl Default for Settings { @@ -317,6 +446,9 @@ impl Default for Settings { // Fixture defaults enable_pan_tilt_limits: true, + + // Push 2 defaults + push2_enabled: false, } } } @@ -431,6 +563,104 @@ pub enum ConsoleEvent { num_peers: u64, }, + // DJ events + DjLibraryUpdated { + track_count: usize, + }, + DjImportProgress { + current: usize, + total: usize, + current_file: String, + }, + DjImportComplete { + imported_count: usize, + skipped_count: usize, + }, + DjTrackLoaded { + deck: u8, + track_id: i64, + title: String, + artist: Option, + duration_seconds: f64, + bpm: Option, + }, + DjDeckStateChanged { + deck: u8, + is_playing: bool, + position_seconds: f64, + bpm: Option, + }, + DjCuePointSet { + deck: u8, + position_seconds: f64, + }, + DjWaveformProgress { + deck: u8, + /// Waveform samples (Arc for zero-copy sharing). + samples: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. + frequency_bands: Option>>, + progress: f32, + }, + DjWaveformLoaded { + deck: u8, + /// Waveform samples (Arc for zero-copy sharing). + samples: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. + frequency_bands: Option>>, + duration_seconds: f64, + }, + DjLibraryTracks { + tracks: Vec, + }, + DjBeatGridLoaded { + deck: u8, + beat_positions: Vec, + first_beat_offset: f64, + bpm: f64, + /// If true, this is a nudge adjustment and position should not change. + is_nudge: bool, + }, + DjMasterTempoChanged { + deck: u8, + enabled: bool, + }, + DjTempoRangeChanged { + deck: u8, + range: u8, + }, + /// Pitch fader position changed (for sync following). + DjPitchChanged { + deck: u8, + pitch_percent: f64, + tempo_range: u8, + adjusted_bpm: f64, + }, + DjLoopStateChanged { + deck: u8, + loop_in: Option, + loop_out: Option, + active: bool, + beat_count: f64, + }, + DjAnalysisProgress { + track_id: i64, + track_name: String, + current: usize, + total: usize, + /// Progress within current track (0.0-1.0) + progress: f32, + }, + DjAnalysisComplete { + track_id: i64, + bpm: Option, + }, + + // Status events + StatusClear, + // Programmer events ProgrammerStateUpdated { preview_mode: bool, diff --git a/crates/core/src/modules/module_manager.rs b/crates/core/src/modules/module_manager.rs index e7e7f78..549de9b 100644 --- a/crates/core/src/modules/module_manager.rs +++ b/crates/core/src/modules/module_manager.rs @@ -108,6 +108,21 @@ impl ModuleManager { } } + /// Broadcast an event to all modules without blocking. + /// Uses try_send which will drop the event if a channel is full. + /// Use this for high-frequency events like position updates. + pub fn try_broadcast_event(&self, event: ModuleEvent) { + for (id, sender) in &self.module_senders { + if let Err(e) = sender.try_send(event.clone()) { + // Only log if it's not a "channel full" error (which is expected for high-frequency + // events) + if !matches!(e, tokio::sync::mpsc::error::TrySendError::Full(_)) { + log::warn!("Failed to broadcast event to module {:?}: {}", id, e); + } + } + } + } + /// Get the message receiver (should only be called once) pub fn take_message_receiver(&mut self) -> Option> { self.message_receiver.take() diff --git a/crates/core/src/modules/traits.rs b/crates/core/src/modules/traits.rs index 2597743..ff44f2f 100644 --- a/crates/core/src/modules/traits.rs +++ b/crates/core/src/modules/traits.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use async_trait::async_trait; use tokio::sync::mpsc; @@ -10,6 +11,8 @@ pub enum ModuleId { Dmx, Smpte, Midi, + Dj, + Push2, } /// Events that can be sent between modules @@ -34,6 +37,113 @@ pub enum ModuleEvent { }, /// MIDI input events MidiInput(crate::midi::midi::MidiMessage), + /// DJ rhythm sync for lighting integration + DjRhythmSync { + bpm: f64, + beat_phase: f64, + bar_phase: f64, + phrase_phase: f64, + }, + /// DJ beat trigger (fired on each beat) + DjBeat { + deck: u8, + beat_number: u64, + is_downbeat: bool, + }, + /// DJ command from console + DjCommand(crate::ConsoleCommand), + /// DJ library tracks response + DjLibraryTracks(Vec), + /// DJ deck loaded event + DjDeckLoaded { + deck: u8, + track_id: i64, + title: String, + artist: Option, + duration_seconds: f64, + bpm: Option, + }, + /// DJ deck state changed + DjDeckStateChanged { + deck: u8, + is_playing: bool, + position_seconds: f64, + bpm: Option, + }, + /// DJ cue point set + DjCuePointSet { + deck: u8, + position_seconds: f64, + }, + /// DJ waveform progress (streaming analysis) + DjWaveformProgress { + deck: u8, + /// Waveform samples (Arc for zero-copy sharing). + samples: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. + frequency_bands: Option>>, + progress: f32, + }, + /// DJ waveform loaded (complete) + DjWaveformLoaded { + deck: u8, + /// Waveform samples (Arc for zero-copy sharing). + samples: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. + frequency_bands: Option>>, + duration_seconds: f64, + }, + /// DJ beat grid loaded + DjBeatGridLoaded { + deck: u8, + beat_positions: Vec, + first_beat_offset: f64, + bpm: f64, + is_nudge: bool, + }, + /// DJ master tempo changed + DjMasterTempoChanged { + deck: u8, + enabled: bool, + }, + /// DJ tempo range changed + DjTempoRangeChanged { + deck: u8, + range: u8, + }, + /// DJ pitch fader position changed (for sync following) + DjPitchChanged { + deck: u8, + pitch_percent: f64, + tempo_range: u8, + adjusted_bpm: f64, + }, + /// DJ loop state changed + DjLoopStateChanged { + deck: u8, + loop_in: Option, + loop_out: Option, + active: bool, + beat_count: f64, + }, + /// DJ track analysis progress (background import) + DjAnalysisProgress { + track_id: i64, + track_name: String, + current: usize, + total: usize, + /// Progress within current track (0.0-1.0) + progress: f32, + }, + /// DJ track analysis complete + DjAnalysisComplete { + track_id: i64, + bpm: Option, + }, + /// Clear status message + StatusClear, /// System events Shutdown, } diff --git a/crates/core/src/rhythm/rhythm.rs b/crates/core/src/rhythm/rhythm.rs index ce05e7b..5ef8350 100644 --- a/crates/core/src/rhythm/rhythm.rs +++ b/crates/core/src/rhythm/rhythm.rs @@ -2,6 +2,29 @@ use std::time::Instant; use serde::{Deserialize, Serialize}; +/// Source for tempo/rhythm synchronization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum TempoSource { + /// Internal tempo (tap tempo, manual BPM setting). + #[default] + Internal, + /// Ableton Link network sync. + AbletonLink, + /// DJ module master deck. + DjMaster, +} + +impl TempoSource { + /// Get a display name for the tempo source. + pub fn display_name(&self) -> &'static str { + match self { + Self::Internal => "Internal", + Self::AbletonLink => "Ableton Link", + Self::DjMaster => "DJ Master", + } + } +} + // Assuming we have access to these from our rhythm engine #[derive(Debug, Clone)] pub struct RhythmState { @@ -12,6 +35,8 @@ pub struct RhythmState { pub bars_per_phrase: u32, pub last_tap_time: Option, pub tap_count: u32, + pub bpm: f64, + pub tempo_source: TempoSource, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/dj/Cargo.toml b/crates/dj/Cargo.toml new file mode 100644 index 0000000..66965ed --- /dev/null +++ b/crates/dj/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "halo-dj" +version = "0.1.0" +authors = ["Rob Morgan "] +edition = "2021" +description = "DJ functionality for Halo lighting console" + +[dependencies] +halo-core = { path = "../core" } +halo-fixtures = { path = "../fixtures" } + +# Async runtime +tokio = { version = "1.48.0", features = ["full"] } +async-trait = "0.1" + +# Audio playback and processing +rodio = "0.21.1" +cpal = "0.17" +symphonia = { version = "0.5", features = [ + "mp3", + "wav", + "aiff", +] } + +# BPM and beat detection +rustfft = "6.2" +aubio-rs = { version = "0.2", features = ["builtin", "bindgen"] } + +# Time-stretching for Master Tempo (SoundTouch WSOLA algorithm) +soundtouch = { version = "0.5", features = ["bundled"] } + +# Database +rusqlite = { version = "0.32", features = ["bundled"] } + +# MIDI (re-export from core for convenience) +midir = "0.10.3" + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" + +# Utilities +anyhow = "1.0.100" +log = "0.4.29" +parking_lot = "0.12.5" +dirs = "6.0" +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2.0" + +[features] +default = ["qm-native"] +# Enable BPM accuracy tests that require external audio files +accuracy-tests = [] +# Use vendored QM-DSP C++ library for tempo detection (enabled by default) +qm-native = [] + +[build-dependencies] +cc = "1.0" + +[dev-dependencies] +env_logger = "0.11" +tempfile = "3.15" diff --git a/crates/dj/build.rs b/crates/dj/build.rs new file mode 100644 index 0000000..03bffac --- /dev/null +++ b/crates/dj/build.rs @@ -0,0 +1,21 @@ +fn main() { + #[cfg(feature = "qm-native")] + { + println!("cargo:rerun-if-changed=vendor/qm-dsp"); + + cc::Build::new() + .cpp(true) + .std("c++11") + .include("vendor/qm-dsp") + // TempoTrackV2 + .file("vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.cpp") + // Maths utilities + .file("vendor/qm-dsp/maths/MathUtilities.cpp") + // C wrapper + .file("vendor/qm-dsp/wrapper.cpp") + // Compiler flags for warnings + .flag_if_supported("-Wno-unused-parameter") + .flag_if_supported("-Wno-sign-compare") + .compile("qm-dsp"); + } +} diff --git a/crates/dj/examples/analyze_track.rs b/crates/dj/examples/analyze_track.rs new file mode 100644 index 0000000..2758381 --- /dev/null +++ b/crates/dj/examples/analyze_track.rs @@ -0,0 +1,203 @@ +//! Track analysis example for testing BPM detection. +//! +//! This example demonstrates importing a track into the library +//! and running BPM/beat-grid analysis. +//! +//! Usage: +//! cargo run --package halo-dj --example analyze_track +//! cargo run --package halo-dj --example analyze_track --dir + +use std::env; +use std::path::Path; + +use halo_dj::library::{ + import_and_analyze_directory, import_and_analyze_file, is_supported_audio_file, LibraryDatabase, +}; + +fn print_usage(program: &str) { + eprintln!("Track Analysis Example"); + eprintln!("======================"); + eprintln!(); + eprintln!("Usage:"); + eprintln!( + " {} Analyze a single file", + program + ); + eprintln!( + " {} --dir Analyze all files in directory", + program + ); + eprintln!( + " {} --dir -r Analyze recursively", + program + ); + eprintln!(); + eprintln!("The library database is stored at: ~/.halo/library.db"); +} + +fn main() -> Result<(), Box> { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let args: Vec = env::args().collect(); + let program = &args[0]; + + if args.len() < 2 { + print_usage(program); + std::process::exit(1); + } + + // Parse arguments + let path = &args[1]; + let is_dir = args.iter().any(|a| a == "--dir" || a == "-d"); + let recursive = args.iter().any(|a| a == "-r" || a == "--recursive"); + + if path == "--help" || path == "-h" { + print_usage(program); + return Ok(()); + } + + println!("Track Analysis Example"); + println!("======================"); + + // Get or create library path + let library_path = dirs::home_dir() + .map(|h| h.join(".halo").join("library.db")) + .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + + // Create library directory if needed + if let Some(parent) = library_path.parent() { + std::fs::create_dir_all(parent)?; + } + + println!("\nLibrary: {}", library_path.display()); + + // Open database + let db = LibraryDatabase::open(&library_path)?; + println!("Database opened successfully"); + + let path = Path::new(path); + + if is_dir { + // Analyze directory + if !path.is_dir() { + eprintln!("Error: {} is not a directory", path.display()); + std::process::exit(1); + } + + println!("\nAnalyzing directory: {}", path.display()); + println!("Recursive: {}", recursive); + println!(); + + let results = import_and_analyze_directory(path, &db, true, recursive); + + let mut success_count = 0; + let mut fail_count = 0; + + for result in results { + match result { + Ok(import_result) => { + success_count += 1; + let track = &import_result.track; + let bpm_str = track.bpm.map_or("N/A".to_string(), |b| format!("{:.1}", b)); + println!( + " [OK] {} - {} ({:.1}s, BPM: {})", + track.artist.as_deref().unwrap_or("Unknown"), + track.title, + track.duration_seconds, + bpm_str + ); + } + Err(e) => { + fail_count += 1; + eprintln!(" [FAIL] {}", e); + } + } + } + + println!(); + println!( + "Summary: {} successful, {} failed", + success_count, fail_count + ); + } else { + // Analyze single file + if !path.exists() { + eprintln!("Error: File not found: {}", path.display()); + std::process::exit(1); + } + + if !is_supported_audio_file(path) { + eprintln!("Error: Unsupported audio format"); + std::process::exit(1); + } + + println!("\nAnalyzing: {}", path.display()); + println!(); + + let start_time = std::time::Instant::now(); + let result = import_and_analyze_file(path, &db, true)?; + let elapsed = start_time.elapsed(); + + let track = &result.track; + println!("Track Information:"); + println!(" Title: {}", track.title); + println!( + " Artist: {}", + track.artist.as_deref().unwrap_or("Unknown") + ); + println!( + " Album: {}", + track.album.as_deref().unwrap_or("Unknown") + ); + println!(" Duration: {:.2}s", track.duration_seconds); + println!(" Sample Rate: {} Hz", track.sample_rate); + println!(" Channels: {}", track.channels); + println!(" Format: {}", track.format.as_str()); + println!(" File Size: {} bytes", track.file_size_bytes); + + if let Some(analysis) = &result.analysis { + println!(); + println!("Analysis Results:"); + println!(" BPM: {:.2}", analysis.bpm); + println!( + " Confidence: {:.2}%", + analysis.beat_grid.confidence * 100.0 + ); + println!( + " First Beat: {:.2}ms", + analysis.beat_grid.first_beat_offset_ms + ); + println!( + " Beat Count: {}", + analysis.beat_grid.beat_positions.len() + ); + println!( + " Waveform: {} samples", + analysis.waveform.sample_count + ); + + // Print first few beat positions + if !analysis.beat_grid.beat_positions.is_empty() { + println!(); + println!("First 8 beat positions (seconds):"); + for (i, &pos) in analysis.beat_grid.beat_positions.iter().take(8).enumerate() { + println!(" Beat {}: {:.3}s", i + 1, pos); + } + } + } else { + println!(); + println!("Analysis: FAILED"); + } + + println!(); + println!("Analysis completed in {:.2}s", elapsed.as_secs_f64()); + } + + // Print library statistics + println!(); + println!("Library Statistics:"); + println!(" Total Tracks: {}", db.track_count()?); + + Ok(()) +} diff --git a/crates/dj/examples/beat_events.rs b/crates/dj/examples/beat_events.rs new file mode 100644 index 0000000..724fde1 --- /dev/null +++ b/crates/dj/examples/beat_events.rs @@ -0,0 +1,144 @@ +//! Beat events example for DJ playback with rhythm tracking. +//! +//! This example demonstrates: +//! - Loading a track with beat grid analysis +//! - Tracking beat and bar phases during playback +//! - Detecting beat triggers (useful for lighting integration) +//! +//! Usage: cargo run --package halo-dj --example beat_events + +use std::time::Duration; +use std::{env, thread}; + +use halo_dj::deck::DeckId; +use halo_dj::library::{AnalysisConfig, AnalysisResult, TrackId}; +use halo_dj::module::{AudioEngineConfig, DjAudioEngine}; + +fn main() -> Result<(), Box> { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // Get audio file from command line + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample: cargo run --package halo-dj --example beat_events path/to/song.mp3"); + std::process::exit(1); + } + let audio_file = &args[1]; + + println!("DJ Beat Events Example"); + println!("======================"); + println!("File: {}\n", audio_file); + + // First, analyze the track for BPM and beat grid + println!("Analyzing track for BPM..."); + let config = AnalysisConfig::default(); + let result: AnalysisResult = + halo_dj::library::analysis::analyze_file(audio_file, TrackId(0), &config)?; + let beat_grid = result.beat_grid; + let bpm = result.bpm; + println!( + "Detected BPM: {:.2} (confidence: {:.1}%)", + bpm, + beat_grid.confidence * 100.0 + ); + println!( + "First beat at: {:.3}s", + beat_grid.first_beat_offset_ms / 1000.0 + ); + println!(); + + // Create audio engine + let config = AudioEngineConfig::default(); + let mut engine = DjAudioEngine::new(config); + + // Start the audio engine + println!("Starting audio engine..."); + engine.start()?; + + // Load the audio file onto Deck A with beat grid + println!("Loading audio file..."); + { + let mut player = engine.deck_player(DeckId::A).write(); + player.load(audio_file)?; + player.set_beat_grid(beat_grid.clone(), bpm); + println!( + "Loaded: {:.2}s @ {:.2} BPM\n", + player.duration_seconds(), + bpm + ); + } + + // Start playback + println!("Starting playback with beat tracking...\n"); + engine.deck_player(DeckId::A).write().play(); + + // Track beats and display rhythm info + let mut last_beat: Option = None; + let beats_per_bar = 4; + + println!("Beat | Bar | Beat Phase | Bar Phase | Phrase Phase | BPM"); + println!("-----|------|------------|-----------|--------------|------"); + + loop { + let (position, duration, state, beat_phase, bar_phase, phrase_phase, beat_num, bpm) = { + let player = engine.deck_player(DeckId::A).read(); + ( + player.position_seconds(), + player.duration_seconds(), + player.state(), + player.beat_phase(), + player.bar_phase(), + player.phrase_phase(), + player.current_beat_number(), + player.effective_bpm(), + ) + }; + + // Check for new beat + if let Some(beat) = beat_num { + if last_beat.map_or(true, |last| beat > last) { + // New beat detected! + let beat_in_bar = (beat % beats_per_bar as u64) + 1; + let bar_number = (beat / beats_per_bar as u64) + 1; + let is_downbeat = beat_in_bar == 1; + + // Print beat info + println!( + "{:4} | {:4} | {:5.3} | {:5.3} | {:5.3} | {:6.2} {}", + beat, + bar_number, + beat_phase.unwrap_or(0.0), + bar_phase.unwrap_or(0.0), + phrase_phase.unwrap_or(0.0), + bpm.unwrap_or(0.0), + if is_downbeat { "**DOWNBEAT**" } else { "" } + ); + + last_beat = Some(beat); + } + } + + // Check if playback finished + if position >= duration - 0.1 { + println!("\nPlayback finished."); + break; + } + + // Check if stopped + if state != halo_dj::module::PlayerState::Playing { + println!("\nPlayback stopped."); + break; + } + + // Sleep briefly to not spam the output + thread::sleep(Duration::from_millis(20)); + } + + // Stop the engine + engine.stop(); + println!("Audio engine stopped."); + + Ok(()) +} diff --git a/crates/dj/examples/multichannel_test.rs b/crates/dj/examples/multichannel_test.rs new file mode 100644 index 0000000..68b5438 --- /dev/null +++ b/crates/dj/examples/multichannel_test.rs @@ -0,0 +1,259 @@ +//! Multi-channel audio test for the DJ module. +//! +//! Tests 4-channel output with separate routing for each deck. +//! Designed for testing with the Motu M4 or similar multi-channel interface. +//! +//! Usage: +//! cargo run --package halo-dj --example multichannel_test -- --list-devices +//! cargo run --package halo-dj --example multichannel_test -- --device "MOTU M4" +//! [file_b] cargo run --package halo-dj --example multichannel_test -- [file_b] + +use std::time::Duration; +use std::{env, thread}; + +use halo_dj::deck::DeckId; +use halo_dj::module::{list_audio_devices, AudioEngineConfig, DjAudioEngine, PlayerState}; + +fn print_usage(program: &str) { + eprintln!("Multi-Channel DJ Audio Test"); + eprintln!("============================"); + eprintln!(); + eprintln!("Usage:"); + eprintln!(" {} --list-devices", program); + eprintln!(" {} [--device ] [file_b]", program); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --list-devices List available audio output devices"); + eprintln!(" --device Select audio device by name (partial match)"); + eprintln!(); + eprintln!("Channel Routing:"); + eprintln!(" Deck A -> Outputs 1-2 (channels 0-1)"); + eprintln!(" Deck B -> Outputs 3-4 (channels 2-3)"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" {} --list-devices", program); + eprintln!(" {} song.mp3", program); + eprintln!(" {} --device \"MOTU M4\" song_a.mp3 song_b.mp3", program); +} + +fn list_devices() { + println!("Available Audio Output Devices:"); + println!("================================"); + + let devices = list_audio_devices(); + if devices.is_empty() { + println!(" No audio output devices found!"); + return; + } + + for (i, device) in devices.iter().enumerate() { + let default_marker = if device.is_default { " (default)" } else { "" }; + println!( + " [{}] {} - {} channels{}", + i, device.name, device.max_channels, default_marker + ); + } + + println!(); + println!("Note: For 4-channel output (DJ mode), select a device with 4+ channels."); +} + +fn main() -> Result<(), Box> { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let args: Vec = env::args().collect(); + let program = &args[0]; + + if args.len() < 2 { + print_usage(program); + std::process::exit(1); + } + + // Parse arguments + let mut device_name = String::new(); + let mut files: Vec = Vec::new(); + let mut i = 1; + + while i < args.len() { + match args[i].as_str() { + "--list-devices" | "-l" => { + list_devices(); + return Ok(()); + } + "--device" | "-d" => { + i += 1; + if i >= args.len() { + eprintln!("Error: --device requires a device name"); + std::process::exit(1); + } + device_name = args[i].clone(); + } + "--help" | "-h" => { + print_usage(program); + return Ok(()); + } + arg if arg.starts_with('-') => { + eprintln!("Unknown option: {}", arg); + print_usage(program); + std::process::exit(1); + } + _ => { + files.push(args[i].clone()); + } + } + i += 1; + } + + if files.is_empty() { + eprintln!("Error: At least one audio file is required"); + print_usage(program); + std::process::exit(1); + } + + println!("Multi-Channel DJ Audio Test"); + println!("============================"); + + // Create audio engine config + let mut config = AudioEngineConfig::default(); + config.device_name = device_name.clone(); + + println!("\nConfiguration:"); + println!( + " Device: {}", + if device_name.is_empty() { + "default" + } else { + &device_name + } + ); + println!(" Sample Rate: {} Hz", config.sample_rate); + println!( + " Deck A: channels {}-{} (outputs 1-2)", + config.deck_a_channels.0, config.deck_a_channels.1 + ); + println!( + " Deck B: channels {}-{} (outputs 3-4)", + config.deck_b_channels.0, config.deck_b_channels.1 + ); + + // Create and start the audio engine + let mut engine = DjAudioEngine::new(config); + + println!("\nStarting audio engine..."); + engine.start()?; + println!( + "Audio engine started with {} output channels", + engine.output_channels() + ); + + if engine.output_channels() < 4 { + println!( + "\nWARNING: Device has only {} channels.", + engine.output_channels() + ); + println!(" Deck B may not output correctly (needs channels 2-3)."); + println!(" Consider using a multi-channel audio interface like Motu M4."); + } + + // Load Deck A + println!("\n--- Deck A ---"); + println!("Loading: {}", files[0]); + { + let mut player = engine.deck_player(DeckId::A).write(); + player.load(&files[0])?; + println!( + "Loaded: {} Hz, {} ch, {:.2}s", + player.sample_rate(), + player.channels(), + player.duration_seconds() + ); + } + + // Load Deck B if a second file is provided + let has_deck_b = files.len() > 1; + if has_deck_b { + println!("\n--- Deck B ---"); + println!("Loading: {}", files[1]); + { + let mut player = engine.deck_player(DeckId::B).write(); + player.load(&files[1])?; + println!( + "Loaded: {} Hz, {} ch, {:.2}s", + player.sample_rate(), + player.channels(), + player.duration_seconds() + ); + } + } + + // Start playback on both decks + println!("\n--- Starting Playback ---"); + engine.deck_player(DeckId::A).write().play(); + if has_deck_b { + engine.deck_player(DeckId::B).write().play(); + } + + println!("Playing (press Ctrl+C to stop)...\n"); + + // Display status loop + loop { + let (pos_a, dur_a, state_a) = { + let player = engine.deck_player(DeckId::A).read(); + ( + player.position_seconds(), + player.duration_seconds(), + player.state(), + ) + }; + + let (pos_b, dur_b, state_b) = if has_deck_b { + let player = engine.deck_player(DeckId::B).read(); + ( + player.position_seconds(), + player.duration_seconds(), + player.state(), + ) + } else { + (0.0, 0.0, PlayerState::Empty) + }; + + // Format time as MM:SS.ss + let fmt_time = + |secs: f64| -> String { format!("{:02}:{:05.2}", (secs / 60.0) as u32, secs % 60.0) }; + + print!( + "\r A: {} / {} [{:?}]", + fmt_time(pos_a), + fmt_time(dur_a), + state_a + ); + + if has_deck_b { + print!( + " | B: {} / {} [{:?}]", + fmt_time(pos_b), + fmt_time(dur_b), + state_b + ); + } + print!(" "); + + // Check if all playback finished + let a_done = state_a != PlayerState::Playing; + let b_done = !has_deck_b || state_b != PlayerState::Playing; + + if a_done && b_done { + println!("\n\nPlayback finished."); + break; + } + + thread::sleep(Duration::from_millis(100)); + } + + // Stop the engine + engine.stop(); + println!("Audio engine stopped."); + + Ok(()) +} diff --git a/crates/dj/examples/play_audio.rs b/crates/dj/examples/play_audio.rs new file mode 100644 index 0000000..95fad0a --- /dev/null +++ b/crates/dj/examples/play_audio.rs @@ -0,0 +1,101 @@ +//! Simple audio playback example for testing the DJ module. +//! +//! Usage: cargo run --package halo-dj --example play_audio + +use std::time::Duration; +use std::{env, thread}; + +use halo_dj::deck::DeckId; +use halo_dj::module::{AudioEngineConfig, DjAudioEngine}; + +fn main() -> Result<(), Box> { + // Initialize logging + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // Get audio file from command line + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample: cargo run --package halo-dj --example play_audio path/to/song.mp3"); + std::process::exit(1); + } + let audio_file = &args[1]; + + println!("DJ Audio Playback Test"); + println!("======================"); + println!("File: {}", audio_file); + + // Create audio engine with default config (4-channel output) + let config = AudioEngineConfig::default(); + println!( + "\nAudio Config:\n Sample Rate: {} Hz\n Deck A: channels {}-{}\n Deck B: channels {}-{}", + config.sample_rate, + config.deck_a_channels.0, + config.deck_a_channels.1, + config.deck_b_channels.0, + config.deck_b_channels.1 + ); + + let mut engine = DjAudioEngine::new(config); + + // Start the audio engine + println!("\nStarting audio engine..."); + engine.start()?; + println!( + "Audio engine started with {} output channels", + engine.output_channels() + ); + + // Load the audio file onto Deck A + println!("\nLoading audio file onto Deck A..."); + { + let mut player = engine.deck_player(DeckId::A).write(); + player.load(audio_file)?; + println!( + "Loaded: {} Hz, {} channels, {:.2}s duration", + player.sample_rate(), + player.channels(), + player.duration_seconds() + ); + } + + // Start playback + println!("\nStarting playback..."); + engine.deck_player(DeckId::A).write().play(); + + // Play for a while, showing position updates + println!("\nPlaying (press Ctrl+C to stop)...\n"); + loop { + let (position, duration, state) = { + let player = engine.deck_player(DeckId::A).read(); + ( + player.position_seconds(), + player.duration_seconds(), + player.state(), + ) + }; + + print!( + "\r Position: {:02}:{:05.2} / {:02}:{:05.2} [{:?}] ", + (position / 60.0) as u32, + position % 60.0, + (duration / 60.0) as u32, + duration % 60.0, + state + ); + + // Check if playback finished + if position >= duration - 0.1 { + println!("\n\nPlayback finished."); + break; + } + + thread::sleep(Duration::from_millis(100)); + } + + // Stop the engine + engine.stop(); + println!("Audio engine stopped."); + + Ok(()) +} diff --git a/crates/dj/src/deck/mod.rs b/crates/dj/src/deck/mod.rs new file mode 100644 index 0000000..5475dbd --- /dev/null +++ b/crates/dj/src/deck/mod.rs @@ -0,0 +1,339 @@ +//! Deck module for DJ deck state and playback control. + +use serde::{Deserialize, Serialize}; + +use crate::library::{BeatGrid, HotCue, MasterTempoMode, TempoRange, Track}; + +/// Deck identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DeckId { + A, + B, +} + +impl DeckId { + /// Get the deck as a numeric index (0 for A, 1 for B). + pub fn index(&self) -> usize { + match self { + Self::A => 0, + Self::B => 1, + } + } + + /// Get the deck as a u8 (0 for A, 1 for B). + pub fn as_u8(&self) -> u8 { + self.index() as u8 + } + + /// Get the deck from a numeric index. + pub fn from_index(index: usize) -> Option { + match index { + 0 => Some(Self::A), + 1 => Some(Self::B), + _ => None, + } + } + + /// Get the other deck. + pub fn other(&self) -> Self { + match self { + Self::A => Self::B, + Self::B => Self::A, + } + } +} + +impl std::fmt::Display for DeckId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::A => write!(f, "A"), + Self::B => write!(f, "B"), + } + } +} + +/// Deck playback state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum DeckState { + /// No track loaded. + #[default] + Empty, + /// Track is loading. + Loading, + /// Track loaded but stopped at start. + Stopped, + /// Track is playing. + Playing, + /// Track is paused. + Paused, + /// Cue preview mode (playing while cue button held). + Cueing, +} + +impl DeckState { + /// Returns true if the deck is actively producing audio. + pub fn is_playing(&self) -> bool { + matches!(self, Self::Playing | Self::Cueing) + } + + /// Returns true if a track is loaded. + pub fn has_track(&self) -> bool { + !matches!(self, Self::Empty | Self::Loading) + } +} + +/// Loop state for quantized looping. +#[derive(Debug, Clone, Copy, Default)] +pub struct LoopState { + /// Loop IN point in seconds (quantized to nearest beat). + pub loop_in: Option, + /// Loop OUT point in seconds (loop_in + beat_count * beat_interval). + pub loop_out: Option, + /// Whether the loop is currently active. + pub active: bool, + /// Number of beats in the current loop (supports 1/32 to 512 beats). + pub beat_count: f64, +} + +impl LoopState { + /// Returns true if a loop is defined (has IN and OUT points). + pub fn is_defined(&self) -> bool { + self.loop_in.is_some() && self.loop_out.is_some() + } + + /// Clear the loop points. + pub fn clear(&mut self) { + self.loop_in = None; + self.loop_out = None; + self.active = false; + self.beat_count = 0.0; + } +} + +/// Complete deck state. +#[derive(Debug, Clone)] +pub struct Deck { + /// Deck identifier. + pub id: DeckId, + /// Current playback state. + pub state: DeckState, + /// Currently loaded track. + pub loaded_track: Option, + /// Beat grid for the loaded track. + pub beat_grid: Option, + + // Playback position + /// Current position in seconds. + pub position_seconds: f64, + /// Current position in beats (from beat grid). + pub position_beats: f64, + + // Tempo + /// Original BPM of the loaded track. + pub original_bpm: f64, + /// Current adjusted BPM (after pitch adjustment). + pub adjusted_bpm: f64, + /// Pitch fader position (-1.0 to 1.0). + pub pitch_percent: f64, + /// Current tempo range setting. + pub tempo_range: TempoRange, + + // Cue points + /// Main cue point position in seconds. + pub cue_point: Option, + /// Position when cue preview started (for returning on release). + pub cue_preview_start: Option, + /// 4 hot cue slots. + pub hot_cues: [Option; 4], + + // Sync + /// Is this deck the tempo master? + pub is_master: bool, + /// Is sync mode enabled? + pub sync_enabled: bool, + + // Master Tempo (key lock) + /// Master Tempo mode (off = varispeed, on = time-stretch). + pub master_tempo: MasterTempoMode, + + // Metering + /// Current volume level (0.0-1.0) for VU meter. + pub volume_level: f32, + /// Peak level for VU meter. + pub peak_level: f32, + + // Looping + /// Current loop state for quantized looping. + pub loop_state: LoopState, +} + +impl Deck { + /// Create a new empty deck. + pub fn new(id: DeckId) -> Self { + Self { + id, + state: DeckState::Empty, + loaded_track: None, + beat_grid: None, + position_seconds: 0.0, + position_beats: 0.0, + original_bpm: 0.0, + adjusted_bpm: 0.0, + pitch_percent: 0.0, + tempo_range: TempoRange::default(), + cue_point: None, + cue_preview_start: None, + hot_cues: [None, None, None, None], + is_master: false, + sync_enabled: false, + master_tempo: MasterTempoMode::Off, + volume_level: 0.0, + peak_level: 0.0, + loop_state: LoopState::default(), + } + } + + /// Calculate the adjusted BPM based on pitch fader position. + pub fn calculate_adjusted_bpm(&self) -> f64 { + self.original_bpm * self.tempo_range.pitch_to_multiplier(self.pitch_percent) + } + + /// Update the adjusted BPM from current pitch setting. + pub fn update_adjusted_bpm(&mut self) { + self.adjusted_bpm = self.calculate_adjusted_bpm(); + } + + /// Get the playback rate multiplier (for audio engine). + pub fn playback_rate(&self) -> f64 { + self.tempo_range.pitch_to_multiplier(self.pitch_percent) + } + + /// Set a hot cue at the given slot. + pub fn set_hot_cue(&mut self, slot: u8, position_seconds: f64) { + if let Some(track) = &self.loaded_track { + let hot_cue = HotCue::new(track.id, slot, position_seconds); + if (slot as usize) < self.hot_cues.len() { + self.hot_cues[slot as usize] = Some(hot_cue); + } + } + } + + /// Clear a hot cue at the given slot. + pub fn clear_hot_cue(&mut self, slot: u8) { + if (slot as usize) < self.hot_cues.len() { + self.hot_cues[slot as usize] = None; + } + } + + /// Load hot cues from a list. + pub fn load_hot_cues(&mut self, hot_cues: Vec) { + self.hot_cues = [None, None, None, None]; + for cue in hot_cues { + let slot = cue.slot as usize; + if slot < self.hot_cues.len() { + self.hot_cues[slot] = Some(cue); + } + } + } + + /// Reset deck to empty state. + pub fn eject(&mut self) { + self.state = DeckState::Empty; + self.loaded_track = None; + self.beat_grid = None; + self.position_seconds = 0.0; + self.position_beats = 0.0; + self.original_bpm = 0.0; + self.adjusted_bpm = 0.0; + self.cue_point = None; + self.cue_preview_start = None; + self.hot_cues = [None, None, None, None]; + self.master_tempo = MasterTempoMode::Off; + self.volume_level = 0.0; + self.peak_level = 0.0; + self.loop_state.clear(); + } + + /// Update beat position from current time position. + pub fn update_beat_position(&mut self) { + if let Some(beat_grid) = &self.beat_grid { + self.position_beats = + beat_grid.beat_at_position(self.position_seconds, self.original_bpm); + } + } + + /// Get the current beat phase (0.0-1.0). + pub fn beat_phase(&self) -> f64 { + if let Some(beat_grid) = &self.beat_grid { + beat_grid.beat_phase_at_position(self.position_seconds, self.original_bpm) + } else { + 0.0 + } + } + + /// Get the current bar phase (0.0-1.0). + pub fn bar_phase(&self) -> f64 { + if let Some(beat_grid) = &self.beat_grid { + beat_grid.bar_phase_at_position(self.position_seconds, self.original_bpm) + } else { + 0.0 + } + } + + /// Get the current phrase phase (0.0-1.0). + pub fn phrase_phase(&self) -> f64 { + if let Some(beat_grid) = &self.beat_grid { + beat_grid.phrase_phase_at_position(self.position_seconds, self.original_bpm) + } else { + 0.0 + } + } +} + +impl Default for Deck { + fn default() -> Self { + Self::new(DeckId::A) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deck_id() { + assert_eq!(DeckId::A.index(), 0); + assert_eq!(DeckId::B.index(), 1); + assert_eq!(DeckId::A.other(), DeckId::B); + assert_eq!(DeckId::B.other(), DeckId::A); + assert_eq!(DeckId::from_index(0), Some(DeckId::A)); + assert_eq!(DeckId::from_index(2), None); + } + + #[test] + fn test_deck_state() { + assert!(DeckState::Playing.is_playing()); + assert!(DeckState::Cueing.is_playing()); + assert!(!DeckState::Paused.is_playing()); + assert!(!DeckState::Empty.has_track()); + assert!(DeckState::Stopped.has_track()); + } + + #[test] + fn test_deck_playback_rate() { + let mut deck = Deck::new(DeckId::A); + deck.tempo_range = TempoRange::Range10; + + // No pitch adjustment + deck.pitch_percent = 0.0; + assert!((deck.playback_rate() - 1.0).abs() < 0.001); + + // +10% pitch + deck.pitch_percent = 1.0; + assert!((deck.playback_rate() - 1.1).abs() < 0.001); + + // -10% pitch + deck.pitch_percent = -1.0; + assert!((deck.playback_rate() - 0.9).abs() < 0.001); + } +} diff --git a/crates/dj/src/lib.rs b/crates/dj/src/lib.rs new file mode 100644 index 0000000..1c1becd --- /dev/null +++ b/crates/dj/src/lib.rs @@ -0,0 +1,24 @@ +//! Halo DJ Module +//! +//! DJ functionality for Halo lighting console with dual-deck playback, +//! beat analysis, and lighting integration. +//! +//! # Features +//! +//! - Two deck audio playback with separate stereo outputs (external mixer mode) +//! - BPM detection and beat grid analysis +//! - SQLite library for track management +//! - Hot cues and cue points +//! - Tempo sync between decks +//! - MIDI controller support (TRAKTOR Z1 MK1) +//! - Lighting integration via RhythmState sync + +pub mod deck; +pub mod library; +pub mod midi; +pub mod module; + +// Re-export main types +pub use deck::{Deck, DeckId, DeckState}; +pub use library::{BeatGrid, HotCue, Track, TrackId, TrackWaveform}; +pub use module::{DjCommand, DjEvent, DjModule}; diff --git a/crates/dj/src/library/analysis.rs b/crates/dj/src/library/analysis.rs new file mode 100644 index 0000000..fd7f200 --- /dev/null +++ b/crates/dj/src/library/analysis.rs @@ -0,0 +1,1439 @@ +//! Audio analysis for BPM detection and beat grid generation. +//! +//! Uses a multi-method approach for robust BPM detection: +//! - Queen Mary-style algorithm (Complex Domain onset + Viterbi tempo tracking) +//! - Aubio Energy and SpecFlux modes +//! - FFT autocorrelation fallback +//! +//! The QM-style algorithm is prioritized as it's the most accurate for most music. + +use std::fs::File; +use std::path::Path; + +use aubio_rs::{OnsetMode, Tempo}; +use chrono::Utc; +use rustfft::num_complex::Complex; +use rustfft::FftPlanner; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use super::qm_native::{median_tempo, NativeTempoTracker}; +use super::types::{BeatGrid, FrequencyBands, TrackId, TrackWaveform}; + +/// Analysis configuration. +#[derive(Debug, Clone)] +pub struct AnalysisConfig { + /// FFT window size for spectral analysis. + pub fft_size: usize, + /// Hop size between FFT windows. + pub hop_size: usize, + /// Minimum BPM to detect. + pub min_bpm: f64, + /// Maximum BPM to detect. + pub max_bpm: f64, + /// Waveform samples per second (resolution). + /// Higher values = smoother zoomed waveforms, more storage. + /// CDJ-3000 style requires ~150-400 samples/second. + pub waveform_samples_per_second: f32, + /// Low frequency band upper limit in Hz (bass, kick drums). + pub low_freq_cutoff: f32, + /// Mid frequency band upper limit in Hz (vocals, instruments). + pub mid_freq_cutoff: f32, +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + fft_size: 2048, + hop_size: 512, + min_bpm: 60.0, + max_bpm: 200.0, + // 150 samples/second gives smooth CDJ-style waveforms when zoomed. + // For a 5-minute track: 300s * 150 = 45,000 samples (~540KB with frequency data). + waveform_samples_per_second: 150.0, + low_freq_cutoff: 250.0, // 20-250 Hz for bass + mid_freq_cutoff: 4000.0, // 250-4000 Hz for mids + } + } +} + +/// Correct octave errors by preferring common dance music tempos. +/// +/// Uses a two-tier preference system: +/// - Primary: 115-135 BPM (house/tech house sweet spot) +/// - Secondary: 80-160 BPM (full dance music range) +/// +/// This prevents 120 BPM tracks from being detected as 60 or 240 BPM. +fn correct_octave_errors_dance(raw_bpm: f64, min_bpm: f64, max_bpm: f64) -> f64 { + let candidates = [raw_bpm, raw_bpm * 2.0, raw_bpm / 2.0]; + + candidates + .into_iter() + .filter(|&bpm| bpm >= min_bpm && bpm <= max_bpm) + .min_by(|&a, &b| { + let score_a = tempo_preference_score(a); + let score_b = tempo_preference_score(b); + score_a + .partial_cmp(&score_b) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(raw_bpm.clamp(min_bpm, max_bpm)) +} + +/// Score a tempo by how close it is to common dance music ranges. +/// Lower score = better (0 = within primary target range). +fn tempo_preference_score(bpm: f64) -> f64 { + // Primary sweet spot: 115-135 BPM (house/techno center) + const PRIMARY_LOW: f64 = 115.0; + const PRIMARY_HIGH: f64 = 135.0; + // Secondary range: 80-160 BPM (hip-hop through techno) + const SECONDARY_LOW: f64 = 80.0; + const SECONDARY_HIGH: f64 = 160.0; + // Center point for scoring + const CENTER: f64 = 125.0; + + if bpm >= PRIMARY_LOW && bpm <= PRIMARY_HIGH { + // Primary range - best score, slight preference toward center + (bpm - CENTER).abs() * 0.01 + } else if bpm >= SECONDARY_LOW && bpm <= SECONDARY_HIGH { + // Secondary range - good but penalized slightly + 10.0 + (bpm - CENTER).abs() * 0.1 + } else { + // Outside range - heavily penalized + 100.0 + (bpm - CENTER).abs() + } +} + +/// Result of audio analysis. +#[derive(Debug, Clone)] +pub struct AnalysisResult { + /// Detected BPM (single source of truth for tempo). + pub bpm: f64, + /// Detected beat grid (first_beat_offset and beat_positions). + pub beat_grid: BeatGrid, + /// Generated waveform. + pub waveform: TrackWaveform, +} + +/// Analyze an audio file for BPM and beat grid. +pub fn analyze_file>( + path: P, + track_id: TrackId, + config: &AnalysisConfig, +) -> Result { + let path = path.as_ref(); + log::info!("Analyzing file: {:?}", path); + + // Load audio samples + let (samples, sample_rate) = load_audio_samples(path)?; + log::debug!("Loaded {} samples at {} Hz", samples.len(), sample_rate); + + // Generate colored waveform with 3-band frequency analysis + let waveform = generate_colored_waveform(&samples, sample_rate, track_id, config); + + // Detect BPM and beat positions using multi-method consensus + let (detected_bpm, detected_confidence, detected_beats) = + detect_beats_aubio(&samples, sample_rate, config); + log::info!( + "Detected BPM: {:.2} (confidence: {:.2}, {} beats)", + detected_bpm, + detected_confidence, + detected_beats.len() + ); + + // Validate and correct beat grid if necessary + let track_duration = samples.len() as f64 / sample_rate as f64; + let (bpm, confidence, beat_positions) = validate_and_correct_beat_grid( + &detected_beats, + detected_bpm, + detected_confidence, + track_duration, + config.min_bpm, + config.max_bpm, + ); + + // Calculate first beat offset from validated beats + let first_beat_offset_ms = beat_positions.first().copied().unwrap_or(0.0) * 1000.0; + + let beat_grid = BeatGrid { + track_id, + first_beat_offset_ms, + beat_positions, + confidence, + analyzed_at: Utc::now(), + }; + + Ok(AnalysisResult { + bpm, + beat_grid, + waveform, + }) +} + +/// Analyze an audio file with streaming waveform progress. +/// +/// Calls `on_waveform_progress` with partial waveform samples as they're generated. +/// This allows the UI to progressively display the waveform during analysis. +pub fn analyze_file_streaming( + path: P, + track_id: TrackId, + config: &AnalysisConfig, + chunk_size: usize, + mut on_waveform_progress: F, +) -> Result +where + P: AsRef, + F: FnMut(Vec, f32), +{ + let path = path.as_ref(); + log::info!("Analyzing file (streaming): {:?}", path); + + // Load audio samples + let (samples, sample_rate) = load_audio_samples(path)?; + log::debug!("Loaded {} samples at {} Hz", samples.len(), sample_rate); + + // Stream amplitude-only progress updates for UI responsiveness + stream_waveform_progress( + &samples, + sample_rate, + config, + chunk_size, + &mut on_waveform_progress, + ); + + // Generate full colored waveform with 3-band FFT analysis + let waveform = generate_colored_waveform(&samples, sample_rate, track_id, config); + + // Detect BPM and beat positions using multi-method consensus + let (detected_bpm, detected_confidence, detected_beats) = + detect_beats_aubio(&samples, sample_rate, config); + log::info!( + "Detected BPM: {:.2} (confidence: {:.2}, {} beats)", + detected_bpm, + detected_confidence, + detected_beats.len() + ); + + // Validate and correct beat grid if necessary + let track_duration = samples.len() as f64 / sample_rate as f64; + let (bpm, confidence, beat_positions) = validate_and_correct_beat_grid( + &detected_beats, + detected_bpm, + detected_confidence, + track_duration, + config.min_bpm, + config.max_bpm, + ); + + // Calculate first beat offset from validated beats + let first_beat_offset_ms = beat_positions.first().copied().unwrap_or(0.0) * 1000.0; + + let beat_grid = BeatGrid { + track_id, + first_beat_offset_ms, + beat_positions, + confidence, + analyzed_at: Utc::now(), + }; + + Ok(AnalysisResult { + bpm, + beat_grid, + waveform, + }) +} + +/// Load audio samples from a file (mono, normalized to -1.0 to 1.0). +fn load_audio_samples>(path: P) -> Result<(Vec, u32), anyhow::Error> { + let path = path.as_ref(); + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow::anyhow!("No audio track found"))?; + + let track_id = track.id; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut decoder = + symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?; + + let mut samples = Vec::new(); + + // Decode all packets + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(e) => { + log::warn!("Error reading packet: {}", e); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + // Convert to mono f32 + append_mono_samples(&mut samples, &decoded); + } + Err(e) => { + log::warn!("Error decoding: {}", e); + } + } + } + + Ok((samples, sample_rate)) +} + +/// Append decoded audio to the sample buffer (converting to mono). +fn append_mono_samples(samples: &mut Vec, decoded: &AudioBufferRef) { + match decoded { + AudioBufferRef::F32(buf) => { + let channels = buf.spec().channels.count(); + for frame in 0..buf.frames() { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame]; + } + samples.push(sum / channels as f32); + } + } + AudioBufferRef::S16(buf) => { + let channels = buf.spec().channels.count(); + for frame in 0..buf.frames() { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32 / 32768.0; + } + samples.push(sum / channels as f32); + } + } + AudioBufferRef::S32(buf) => { + let channels = buf.spec().channels.count(); + for frame in 0..buf.frames() { + let mut sum = 0.0; + for ch in 0..channels { + sum += buf.chan(ch)[frame] as f32 / 2147483648.0; + } + samples.push(sum / channels as f32); + } + } + _ => {} + } +} + +/// Detect BPM using FFT-based autocorrelation on the onset envelope. +/// +/// This algorithm is more accurate than SoundTouch for precise BPM detection: +/// 1. Calculates onset envelope using spectral flux in bass frequencies +/// 2. Computes autocorrelation using FFT (Wiener-Khinchin theorem) +/// 3. Finds peaks in the autocorrelation corresponding to beat intervals +/// 4. Selects the highest peak within the configured BPM range +fn detect_bpm(samples: &[f32], sample_rate: u32, config: &AnalysisConfig) -> (f64, f32) { + if samples.len() < sample_rate as usize * 4 { + // Need at least 4 seconds for reliable detection + return (120.0, 0.0); + } + + // Calculate onset envelope from bass frequencies (better for kick detection) + let onset_env = calculate_bass_onset_envelope(samples, sample_rate, config); + + if onset_env.len() < 256 { + return (120.0, 0.0); + } + + // Time resolution of onset envelope + let hop_time = config.hop_size as f64 / sample_rate as f64; + + // Compute autocorrelation using FFT (Wiener-Khinchin theorem) + // This is O(n log n) vs O(n^2) for direct computation + let autocorr = compute_fft_autocorrelation(&onset_env); + + // Convert BPM range to lag range (in onset envelope samples) + let min_lag = (60.0 / config.max_bpm / hop_time) as usize; + let max_lag = (60.0 / config.min_bpm / hop_time) as usize; + + // Ensure we have enough autocorrelation data + let max_lag = max_lag.min(autocorr.len() / 2); + if max_lag <= min_lag { + return (120.0, 0.0); + } + + // Find peaks in autocorrelation within the BPM range + let mut peaks: Vec<(usize, f32)> = Vec::new(); + for lag in min_lag..max_lag { + let val = autocorr[lag]; + let prev = if lag > 0 { autocorr[lag - 1] } else { 0.0 }; + let next = if lag + 1 < autocorr.len() { + autocorr[lag + 1] + } else { + 0.0 + }; + + // Local maximum detection + if val > prev && val > next && val > 0.0 { + peaks.push((lag, val)); + } + } + + if peaks.is_empty() { + return (120.0, 0.0); + } + + // Sort peaks by strength (descending) + peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Find the best peak, preferring stronger peaks but also considering + // if a peak at half the lag has similar strength (octave detection) + let best_lag = peaks[0].0; + let best_strength = peaks[0].1; + + // Check for octave ambiguity: if there's a peak at 2x the frequency (half lag) + // with similar strength, prefer the higher frequency (shorter lag) + let mut final_lag = best_lag; + let half_lag = best_lag / 2; + if half_lag >= min_lag { + // Look for a peak near half the lag + for &(lag, strength) in &peaks { + if lag >= half_lag.saturating_sub(2) + && lag <= half_lag + 2 + && strength > best_strength * 0.7 + { + // Found a strong peak at half the lag, prefer it + final_lag = lag; + break; + } + } + } + + // Convert lag to BPM with parabolic interpolation for sub-sample accuracy + let refined_lag = if final_lag > 0 && final_lag + 1 < autocorr.len() { + let y0 = autocorr[final_lag - 1]; + let y1 = autocorr[final_lag]; + let y2 = autocorr[final_lag + 1]; + let offset = (y0 - y2) / (2.0 * (y0 - 2.0 * y1 + y2)); + if offset.is_finite() && offset.abs() < 1.0 { + final_lag as f64 + offset as f64 + } else { + final_lag as f64 + } + } else { + final_lag as f64 + }; + + let beat_interval = refined_lag * hop_time; + let bpm = 60.0 / beat_interval; + + // Calculate confidence based on peak prominence + let max_autocorr = autocorr[1..].iter().cloned().fold(0.0f32, f32::max); + let peak_val = autocorr[final_lag]; + let confidence = if max_autocorr > 0.0 { + (peak_val / max_autocorr).min(1.0) + } else { + 0.0 + }; + + // Clamp to valid range + let bpm = bpm.clamp(config.min_bpm, config.max_bpm); + + log::debug!( + "FFT autocorr BPM: {:.2}, lag: {:.2}, confidence: {:.2}", + bpm, + refined_lag, + confidence + ); + + (bpm, confidence) +} + +/// Detect BPM and beat positions using multi-method consensus. +/// +/// Runs multiple detection methods and selects the best result: +/// 1. Queen Mary-style (Complex Domain + Viterbi) - most accurate, prioritized +/// When qm-native feature is enabled, uses actual C++ QM-DSP library. +/// 2. Aubio Energy mode - good for kick drums in dance music +/// 3. Aubio SpecFlux mode - aubio's recommended for general tempo detection +/// 4. FFT autocorrelation - fallback +/// +/// Returns (bpm, confidence, beat_positions_in_seconds). +fn detect_beats_aubio( + samples: &[f32], + sample_rate: u32, + config: &AnalysisConfig, +) -> (f64, f32, Vec) { + // Method 1: Queen Mary-style detection using native C++ QM-DSP library (highest priority) + let (bpm_qm, conf_qm, beats_qm) = detect_tempo_native(samples, sample_rate, config); + + // Method 2: Energy mode (good for kick drums in dance music) + let (bpm_energy, conf_energy, beats_energy) = + detect_beats_aubio_with_mode(samples, sample_rate, config, OnsetMode::Energy); + + // Method 3: SpecFlux mode (aubio's recommended for general tempo detection) + let (bpm_specflux, conf_specflux, beats_specflux) = + detect_beats_aubio_with_mode(samples, sample_rate, config, OnsetMode::SpecFlux); + + // Method 4: FFT autocorrelation on bass envelope (fallback) + let (bpm_fft, conf_fft) = detect_bpm(samples, sample_rate, config); + + log::debug!( + "Multi-method BPM: QM={:.2} (conf={:.2}), Energy={:.2} (conf={:.2}), SpecFlux={:.2} (conf={:.2}), FFT={:.2} (conf={:.2})", + bpm_qm, conf_qm, bpm_energy, conf_energy, bpm_specflux, conf_specflux, bpm_fft, conf_fft + ); + + // Select best result using enhanced consensus with QM priority + select_best_bpm_consensus_with_qm( + bpm_qm, + conf_qm, + &beats_qm, + bpm_energy, + conf_energy, + &beats_energy, + bpm_specflux, + conf_specflux, + &beats_specflux, + bpm_fft, + conf_fft, + ) +} + +/// Select the best BPM from multiple detection methods using enhanced consensus with QM priority. +/// +/// The Queen Mary-style detector is prioritized when: +/// 1. It has good confidence (>= 0.4) +/// 2. At least one other method agrees with it +/// +/// Scoring considers: +/// - Detection confidence (QM weighted higher) +/// - Agreement between methods +/// - Proximity to common dance tempos +#[allow(clippy::too_many_arguments)] +fn select_best_bpm_consensus_with_qm( + bpm_qm: f64, + conf_qm: f32, + beats_qm: &[f64], + bpm_energy: f64, + conf_energy: f32, + beats_energy: &[f64], + bpm_specflux: f64, + conf_specflux: f32, + beats_specflux: &[f64], + bpm_fft: f64, + conf_fft: f32, +) -> (f64, f32, Vec) { + // Check agreement between methods + let qm_agrees_energy = bpms_agree(bpm_qm, bpm_energy); + let qm_agrees_specflux = bpms_agree(bpm_qm, bpm_specflux); + let qm_agrees_fft = bpms_agree(bpm_qm, bpm_fft); + let energy_agrees_specflux = bpms_agree(bpm_energy, bpm_specflux); + let energy_agrees_fft = bpms_agree(bpm_energy, bpm_fft); + let specflux_agrees_fft = bpms_agree(bpm_specflux, bpm_fft); + + // Count how many methods agree with QM + let qm_agreement_count = + qm_agrees_energy as u32 + qm_agrees_specflux as u32 + qm_agrees_fft as u32; + + // Priority 1: QM has good confidence and at least one other method agrees + if conf_qm >= 0.4 && qm_agreement_count >= 1 && !beats_qm.is_empty() { + let boost = 1.0 + (qm_agreement_count as f32 * 0.1); // Boost confidence with agreement + let combined_conf = (conf_qm * boost).min(1.0); + log::debug!( + "Consensus: QM wins with {} agreeing methods ({:.2} BPM, conf={:.2})", + qm_agreement_count, + bpm_qm, + combined_conf + ); + return (bpm_qm, combined_conf, beats_qm.to_vec()); + } + + // Priority 2: QM has high confidence even without agreement + if conf_qm >= 0.6 && !beats_qm.is_empty() { + log::debug!( + "Consensus: QM wins on high confidence ({:.2} BPM, conf={:.2})", + bpm_qm, + conf_qm + ); + return (bpm_qm, conf_qm, beats_qm.to_vec()); + } + + // Priority 3: Majority agreement among other methods + let other_agreement_count = + energy_agrees_specflux as u32 + energy_agrees_fft as u32 + specflux_agrees_fft as u32; + + if other_agreement_count >= 2 { + // All three non-QM methods agree + let best_conf = conf_energy.max(conf_specflux); + if conf_energy >= conf_specflux { + log::debug!( + "Consensus: Energy/SpecFlux/FFT all agree, using Energy ({:.2} BPM)", + bpm_energy + ); + return (bpm_energy, best_conf, beats_energy.to_vec()); + } else { + log::debug!( + "Consensus: Energy/SpecFlux/FFT all agree, using SpecFlux ({:.2} BPM)", + bpm_specflux + ); + return (bpm_specflux, best_conf, beats_specflux.to_vec()); + } + } + + // Priority 4: Two methods agree + if energy_agrees_specflux { + let combined_conf = (conf_energy + conf_specflux) / 2.0; + if conf_energy >= conf_specflux { + log::debug!( + "Consensus: Energy and SpecFlux agree, using Energy ({:.2} BPM)", + bpm_energy + ); + return (bpm_energy, combined_conf, beats_energy.to_vec()); + } else { + log::debug!( + "Consensus: Energy and SpecFlux agree, using SpecFlux ({:.2} BPM)", + bpm_specflux + ); + return (bpm_specflux, combined_conf, beats_specflux.to_vec()); + } + } + + if energy_agrees_fft { + log::debug!( + "Consensus: Energy and FFT agree, using Energy ({:.2} BPM)", + bpm_energy + ); + return ( + bpm_energy, + (conf_energy + conf_fft) / 2.0, + beats_energy.to_vec(), + ); + } + + if specflux_agrees_fft { + log::debug!( + "Consensus: SpecFlux and FFT agree, using SpecFlux ({:.2} BPM)", + bpm_specflux + ); + return ( + bpm_specflux, + (conf_specflux + conf_fft) / 2.0, + beats_specflux.to_vec(), + ); + } + + // Priority 5: No agreement - use weighted scoring + // QM gets a 1.5x weight since it uses more sophisticated analysis + let score_qm = conf_qm as f64 * 15.0 - tempo_preference_score(bpm_qm) * 0.3; + let score_energy = conf_energy as f64 * 10.0 - tempo_preference_score(bpm_energy) * 0.5; + let score_specflux = conf_specflux as f64 * 10.0 - tempo_preference_score(bpm_specflux) * 0.5; + let score_fft = conf_fft as f64 * 10.0 - tempo_preference_score(bpm_fft) * 0.5; + + // Find the best score + let mut best_score = score_qm; + let mut best_method = "QM"; + let mut best_bpm = bpm_qm; + let mut best_conf = conf_qm; + let mut best_beats = beats_qm; + + if score_energy > best_score { + best_score = score_energy; + best_method = "Energy"; + best_bpm = bpm_energy; + best_conf = conf_energy; + best_beats = beats_energy; + } + if score_specflux > best_score { + best_score = score_specflux; + best_method = "SpecFlux"; + best_bpm = bpm_specflux; + best_conf = conf_specflux; + best_beats = beats_specflux; + } + if score_fft > best_score { + best_method = "FFT"; + best_bpm = bpm_fft; + best_conf = conf_fft; + // FFT doesn't provide beats, use the best available + best_beats = if !beats_qm.is_empty() { + beats_qm + } else if !beats_energy.is_empty() { + beats_energy + } else { + beats_specflux + }; + } + + log::debug!( + "Consensus: no agreement, using {} ({:.2} BPM, score={:.2})", + best_method, + best_bpm, + best_score + ); + + (best_bpm, best_conf, best_beats.to_vec()) +} + +/// Validate and correct beat grid to ensure intervals match the detected BPM. +/// +/// If the median beat interval differs significantly from the expected interval, +/// recalculates the BPM from the actual intervals and regenerates a consistent grid. +/// +/// Returns (corrected_bpm, corrected_confidence, corrected_beats). +fn validate_and_correct_beat_grid( + beats: &[f64], + detected_bpm: f64, + detected_confidence: f32, + track_duration: f64, + min_bpm: f64, + max_bpm: f64, +) -> (f64, f32, Vec) { + if beats.len() < 8 { + // Not enough beats to validate - return original + return (detected_bpm, detected_confidence, beats.to_vec()); + } + + // Calculate actual intervals between detected beats + let intervals: Vec = beats.windows(2).map(|w| w[1] - w[0]).collect(); + + // Calculate median interval (robust against outliers) + let mut sorted_intervals = intervals.clone(); + sorted_intervals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median_interval = sorted_intervals[sorted_intervals.len() / 2]; + + // Calculate BPM from median interval + let actual_bpm = 60.0 / median_interval; + + // Check if detected BPM matches actual beat intervals + let expected_interval = 60.0 / detected_bpm; + let interval_error = (median_interval - expected_interval).abs() / expected_interval; + + if interval_error <= 0.02 { + // Within 2% tolerance - beat grid is consistent + log::debug!( + "Beat grid validation passed: detected={:.2} BPM, actual intervals suggest {:.2} BPM", + detected_bpm, + actual_bpm + ); + return (detected_bpm, detected_confidence, beats.to_vec()); + } + + // BPM mismatch - recalculate from beat intervals + log::info!( + "Beat grid validation: BPM mismatch! Detected {:.2}, intervals suggest {:.2} (error={:.1}%)", + detected_bpm, + actual_bpm, + interval_error * 100.0 + ); + + // Apply octave correction to the actual BPM + let corrected_bpm = correct_octave_errors_dance(actual_bpm, min_bpm, max_bpm); + let corrected_interval = 60.0 / corrected_bpm; + + // Regenerate consistent beat grid from first beat + let first_beat = beats[0]; + let num_beats = ((track_duration - first_beat) / corrected_interval).ceil() as usize; + + let corrected_beats: Vec = (0..num_beats) + .map(|i| first_beat + i as f64 * corrected_interval) + .filter(|&t| t < track_duration) + .collect(); + + // Reduce confidence since we had to correct + let corrected_confidence = (detected_confidence * 0.8).max(0.3); + + log::info!( + "Beat grid corrected: {:.2} BPM -> {:.2} BPM, {} beats", + detected_bpm, + corrected_bpm, + corrected_beats.len() + ); + + (corrected_bpm, corrected_confidence, corrected_beats) +} + +/// Check if two BPM values agree (within 2% or octave relationship). +fn bpms_agree(bpm1: f64, bpm2: f64) -> bool { + let ratio = bpm1 / bpm2; + let tolerance = 0.02; + + // Check direct agreement + if (ratio - 1.0).abs() < tolerance { + return true; + } + // Check octave relationships (2x or 0.5x) + if (ratio - 2.0).abs() < tolerance || (ratio - 0.5).abs() < tolerance { + return true; + } + false +} + +/// Detect BPM and beat positions using aubio's Tempo tracker with a specific onset mode. +/// +/// Returns (bpm, confidence, beat_positions_in_seconds). +fn detect_beats_aubio_with_mode( + samples: &[f32], + sample_rate: u32, + config: &AnalysisConfig, + onset_mode: OnsetMode, +) -> (f64, f32, Vec) { + let buf_size = 1024; + let hop_size = 512; + + // Create aubio Tempo detector with specified onset mode + let mut tempo = match Tempo::new(onset_mode, buf_size, hop_size, sample_rate) { + Ok(t) => t, + Err(e) => { + log::warn!("Failed to create aubio Tempo: {}", e); + return (120.0, 0.0, Vec::new()); + } + }; + + // Process audio in chunks and collect beat positions + let mut beat_positions: Vec = Vec::new(); + let mut output = vec![0.0f32; 1]; + + for (chunk_idx, chunk) in samples.chunks(hop_size).enumerate() { + // Pad last chunk if needed + let input: Vec = if chunk.len() < hop_size { + let mut padded = chunk.to_vec(); + padded.resize(hop_size, 0.0); + padded + } else { + chunk.to_vec() + }; + + // Process chunk through tempo detector + if tempo.do_(&input, &mut output).is_ok() { + // If output[0] > 0, a beat was detected at this position + if output[0] > 0.0 { + let beat_time = chunk_idx as f64 * hop_size as f64 / sample_rate as f64; + beat_positions.push(beat_time); + } + } + } + + // Get final BPM and confidence from aubio + let raw_bpm = tempo.get_bpm() as f64; + let confidence = tempo.get_confidence(); + + // Apply dance-tempo-aware octave correction + let bpm = if raw_bpm > 0.0 { + correct_octave_errors_dance(raw_bpm, config.min_bpm, config.max_bpm) + } else { + 120.0 // Fallback for invalid detection + }; + + log::debug!( + "Aubio BPM: {:.2}, confidence: {:.2}, beats detected: {}", + bpm, + confidence, + beat_positions.len() + ); + + // Refine beat positions to align with actual transients + let refined_positions = refine_beats_to_transients(samples, sample_rate, &beat_positions); + + log::debug!( + "Refined {} beats to transient positions", + refined_positions.len() + ); + + (bpm, confidence, refined_positions) +} + +/// Refine all beat positions to align with actual transients. +/// +/// Takes coarse beat positions (from aubio) and refines each one to the +/// precise transient onset within a search window. +fn refine_beats_to_transients(samples: &[f32], sample_rate: u32, coarse_beats: &[f64]) -> Vec { + coarse_beats + .iter() + .map(|&beat_time| refine_beat_to_transient(samples, sample_rate, beat_time)) + .collect() +} + +/// Refine a single beat position to the nearest transient onset. +/// +/// Searches within ±30ms of the coarse beat position to find the exact +/// sample where the transient attack begins. Uses onset detection based +/// on energy increase rate, focusing on low frequencies (kick drums). +/// +/// The algorithm: +/// 1. Extract a window of samples around the coarse beat position +/// 2. Apply a simple low-pass filter to focus on kick drum frequencies +/// 3. Calculate the onset function (rate of energy increase) +/// 4. Find the maximum onset within the window +/// 5. Return the refined time position +fn refine_beat_to_transient(samples: &[f32], sample_rate: u32, coarse_beat_time: f64) -> f64 { + // Search window: ±50ms around the coarse beat (wider for better kick detection) + let window_ms = 50.0; + let window_samples = ((window_ms / 1000.0) * sample_rate as f64) as usize; + + let beat_sample = (coarse_beat_time * sample_rate as f64) as usize; + let start = beat_sample.saturating_sub(window_samples); + let end = (beat_sample + window_samples).min(samples.len()); + + if end <= start + 10 { + return coarse_beat_time; + } + + // Simple low-pass filter for kick drum emphasis (moving average) + // This smooths high frequencies while preserving the kick transient + let filter_size = (sample_rate / 2000) as usize; // ~500Hz cutoff + let filter_size = filter_size.max(4).min(32); + + // Calculate filtered energy in small windows + let hop = filter_size / 2; + let mut energies: Vec<(usize, f32)> = Vec::new(); + + let mut i = start; + while i + filter_size <= end { + // Calculate RMS energy in this small window + let energy: f32 = samples[i..i + filter_size] + .iter() + .map(|&s| s * s) + .sum::() + / filter_size as f32; + energies.push((i + filter_size / 2, energy.sqrt())); + i += hop; + } + + if energies.len() < 3 { + return coarse_beat_time; + } + + // Calculate onset function (positive derivative of energy) + // The transient is where energy increases most rapidly + let mut max_onset = 0.0f32; + let mut max_onset_idx = beat_sample; + + for i in 1..energies.len() { + let onset = (energies[i].1 - energies[i - 1].1).max(0.0); + + // Weight by proximity to original beat (prefer refinements close to the coarse position) + let distance_from_beat = + (energies[i].0 as f64 - beat_sample as f64).abs() / window_samples as f64; + let proximity_weight = 1.0 - (distance_from_beat * 0.3) as f32; // Mild preference for center + + let weighted_onset = onset * proximity_weight; + + if weighted_onset > max_onset { + max_onset = weighted_onset; + max_onset_idx = energies[i].0; + } + } + + // If no significant onset found, return original + if max_onset < 0.001 { + return coarse_beat_time; + } + + // Further refine: find the exact sample where the attack starts + // Look backwards from the onset peak to find where energy starts rising + let attack_search_start = max_onset_idx.saturating_sub(filter_size * 2); + let attack_search_end = max_onset_idx.min(samples.len()); + + if attack_search_end > attack_search_start { + // Find the sample with steepest positive slope (attack start) + let mut max_slope = 0.0f32; + let mut attack_sample = max_onset_idx; + + for i in (attack_search_start + 1)..attack_search_end { + let slope = samples[i].abs() - samples[i - 1].abs(); + if slope > max_slope { + max_slope = slope; + attack_sample = i; + } + } + + if max_slope > 0.01 { + return attack_sample as f64 / sample_rate as f64; + } + } + + max_onset_idx as f64 / sample_rate as f64 +} + +/// Compute autocorrelation using FFT (Wiener-Khinchin theorem). +/// +/// The autocorrelation of a signal equals the inverse FFT of its power spectrum. +/// This is O(n log n) compared to O(n^2) for direct computation. +fn compute_fft_autocorrelation(signal: &[f32]) -> Vec { + // Pad to power of 2 for efficient FFT + let n = signal.len().next_power_of_two() * 2; + + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(n); + let ifft = planner.plan_fft_inverse(n); + + // Zero-pad signal + let mut buffer: Vec> = signal + .iter() + .map(|&x| Complex::new(x, 0.0)) + .chain(std::iter::repeat(Complex::new(0.0, 0.0))) + .take(n) + .collect(); + + // Forward FFT + fft.process(&mut buffer); + + // Compute power spectrum (|X(f)|^2) + for c in &mut buffer { + *c = Complex::new(c.norm_sqr(), 0.0); + } + + // Inverse FFT to get autocorrelation + ifft.process(&mut buffer); + + // Normalize and return real part + let norm = 1.0 / n as f32; + buffer.iter().map(|c| c.re * norm).collect() +} + +/// Calculate low-frequency (bass) onset envelope for kick drum detection. +/// +/// Focuses on 40-200 Hz range where kick drums have most energy. +fn calculate_bass_onset_envelope( + samples: &[f32], + sample_rate: u32, + config: &AnalysisConfig, +) -> Vec { + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(config.fft_size); + + let mut onset_env = Vec::new(); + let mut prev_bass_energy = 0.0f32; + + // Frequency bins for bass range (40-200 Hz) + let bin_width = sample_rate as f32 / config.fft_size as f32; + let bass_low_bin = (40.0 / bin_width) as usize; + let bass_high_bin = (200.0 / bin_width) as usize; + + let window: Vec = (0..config.fft_size) + .map(|i| { + 0.5 * (1.0 + - (2.0 * std::f32::consts::PI * i as f32 / (config.fft_size - 1) as f32).cos()) + }) + .collect(); + + for start in (0..samples.len().saturating_sub(config.fft_size)).step_by(config.hop_size) { + // Apply window and compute FFT + let mut buffer: Vec> = samples[start..start + config.fft_size] + .iter() + .zip(window.iter()) + .map(|(s, w)| Complex::new(s * w, 0.0)) + .collect(); + + fft.process(&mut buffer); + + // Calculate bass energy (sum of magnitudes in bass range) + let bass_energy: f32 = buffer[bass_low_bin..=bass_high_bin.min(buffer.len() - 1)] + .iter() + .map(|c| c.norm()) + .sum(); + + // Half-wave rectified difference (onset = increase in bass energy) + let onset = (bass_energy - prev_bass_energy).max(0.0); + onset_env.push(onset); + + prev_bass_energy = bass_energy; + } + + onset_env +} + +/// Detect BPM using native QM-DSP TempoTrackV2 library. +/// +/// This uses the actual C++ QM-DSP implementation for maximum accuracy. +/// The detection function is computed from spectral flux. +pub fn detect_tempo_native( + samples: &[f32], + sample_rate: u32, + config: &AnalysisConfig, +) -> (f64, f32, Vec) { + if samples.len() < sample_rate as usize * 4 { + return (120.0, 0.0, Vec::new()); + } + + // Use spectral flux detection function (similar to QM-DSP's Complex onset mode) + let df = compute_detection_function_for_native(samples, sample_rate, config); + + if df.len() < 256 { + log::warn!("Detection function too short for native QM-DSP"); + return (120.0, 0.0, Vec::new()); + } + + // Create native tracker with sample rate and hop size + let mut tracker = NativeTempoTracker::new(sample_rate as f32, config.hop_size as i32); + + // Calculate beat periods and tempi + let (beat_periods, tempi) = tracker.calculate_beat_period(&df); + + if tempi.is_empty() { + log::warn!("Native QM-DSP returned no tempo estimates"); + return (120.0, 0.0, Vec::new()); + } + + // Get median BPM from per-frame tempi + let raw_bpm = median_tempo(&tempi); + + // Apply octave correction for dance music + let bpm = correct_octave_errors_dance(raw_bpm, config.min_bpm, config.max_bpm); + + // Calculate beat positions from beat periods + let beats_frames = tracker.calculate_beats(&df, &beat_periods); + + // Convert beat positions from frame units to seconds + let hop_time = config.hop_size as f64 / sample_rate as f64; + let beats: Vec = beats_frames + .iter() + .map(|&frame| frame * hop_time) + .collect(); + + // Calculate confidence based on tempo estimate consistency + let confidence = if tempi.len() > 10 { + let mean = tempi.iter().sum::() / tempi.len() as f64; + let variance = + tempi.iter().map(|&t| (t - mean).powi(2)).sum::() / tempi.len() as f64; + let std_dev = variance.sqrt(); + let cv = std_dev / mean; // Coefficient of variation + // Lower CV = more consistent = higher confidence + (1.0 - cv.min(0.5) * 2.0).max(0.3) as f32 + } else { + 0.5 + }; + + log::debug!( + "Native QM-DSP: raw={:.2} BPM, corrected={:.2} BPM, confidence={:.2}, {} beats", + raw_bpm, + bpm, + confidence, + beats.len() + ); + + (bpm, confidence, beats) +} + +/// Compute detection function for native QM-DSP tempo tracker. +/// +/// Uses complex spectral difference (similar to QM-DSP's ComplexOD onset detector). +fn compute_detection_function_for_native( + samples: &[f32], + _sample_rate: u32, + config: &AnalysisConfig, +) -> Vec { + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(config.fft_size); + + let mut df = Vec::new(); + let mut prev_mag = vec![0.0f32; config.fft_size / 2 + 1]; + let mut prev_phase = vec![0.0f32; config.fft_size / 2 + 1]; + let mut prev_prev_phase = vec![0.0f32; config.fft_size / 2 + 1]; + + // Hanning window + let window: Vec = (0..config.fft_size) + .map(|i| { + 0.5 * (1.0 + - (2.0 * std::f32::consts::PI * i as f32 / (config.fft_size - 1) as f32).cos()) + }) + .collect(); + + for start in (0..samples.len().saturating_sub(config.fft_size)).step_by(config.hop_size) { + // Apply window and compute FFT + let mut buffer: Vec> = samples[start..start + config.fft_size] + .iter() + .zip(window.iter()) + .map(|(s, w)| Complex::new(s * w, 0.0)) + .collect(); + + fft.process(&mut buffer); + + // Complex spectral difference (CSD) detection function + // Measures both magnitude and phase changes + let mut sum = 0.0f64; + + for (bin, c) in buffer.iter().enumerate().take(config.fft_size / 2 + 1) { + let mag = c.norm(); + let phase = c.arg(); + + // Phase deviation: difference from expected phase based on previous two frames + let expected_phase = 2.0 * prev_phase[bin] - prev_prev_phase[bin]; + let phase_dev = princarg(phase - expected_phase); + + // Target magnitude and phase for "no change" case + let target = Complex::from_polar(prev_mag[bin], expected_phase); + let actual = Complex::new(mag * phase_dev.cos(), mag * phase_dev.sin()); + + // Euclidean distance in complex plane (half-wave rectified) + let diff = (actual - target).norm(); + sum += diff as f64; + + // Store for next frame + prev_prev_phase[bin] = prev_phase[bin]; + prev_phase[bin] = phase; + prev_mag[bin] = mag; + } + + df.push(sum); + } + + df +} + +/// Principal argument function: wrap phase to [-π, π) +fn princarg(phase: f32) -> f32 { + let mut p = phase; + while p >= std::f32::consts::PI { + p -= 2.0 * std::f32::consts::PI; + } + while p < -std::f32::consts::PI { + p += 2.0 * std::f32::consts::PI; + } + p +} + +/// Generate colored waveform with 3-band frequency analysis for visualization. +/// +/// Uses FFT to extract low, mid, and high frequency energy for each waveform sample. +/// - Low: 20-250 Hz (bass, kick drums) -> Red +/// - Mid: 250-4000 Hz (vocals, instruments) -> Green +/// - High: 4000+ Hz (hi-hats, cymbals) -> Blue +fn generate_colored_waveform( + audio_samples: &[f32], + sample_rate: u32, + track_id: TrackId, + config: &AnalysisConfig, +) -> TrackWaveform { + let duration_seconds = audio_samples.len() as f64 / sample_rate as f64; + + // Calculate target samples based on duration and samples-per-second config + // This gives consistent resolution regardless of track length + let target_samples = + ((duration_seconds as f32 * config.waveform_samples_per_second).ceil() as usize).max(100); + + if audio_samples.is_empty() { + return TrackWaveform { + track_id, + samples: vec![0.0; target_samples], + frequency_bands: Some(vec![FrequencyBands::default(); target_samples]), + sample_count: target_samples, + duration_seconds: 0.0, + }; + } + + let samples_per_bucket = audio_samples.len() / target_samples.max(1); + + // FFT setup + let fft_size = config.fft_size; + let mut planner = FftPlanner::new(); + let fft = planner.plan_fft_forward(fft_size); + + // Hanning window for smoother FFT + let window: Vec = (0..fft_size) + .map(|i| { + 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (fft_size - 1) as f32).cos()) + }) + .collect(); + + // Frequency bin calculations + let freq_resolution = sample_rate as f32 / fft_size as f32; + let low_bin_end = (config.low_freq_cutoff / freq_resolution).round() as usize; + let mid_bin_end = (config.mid_freq_cutoff / freq_resolution).round() as usize; + let nyquist_bin = fft_size / 2; + + let mut waveform_samples = Vec::with_capacity(target_samples); + let mut frequency_bands = Vec::with_capacity(target_samples); + + for i in 0..target_samples { + let bucket_start = i * samples_per_bucket; + let bucket_end = ((i + 1) * samples_per_bucket).min(audio_samples.len()); + + if bucket_start >= audio_samples.len() { + waveform_samples.push(0.0); + frequency_bands.push(FrequencyBands::default()); + continue; + } + + // Calculate peak amplitude for this bucket + let peak = audio_samples[bucket_start..bucket_end] + .iter() + .map(|s| s.abs()) + .fold(0.0f32, f32::max); + waveform_samples.push(peak); + + // Find center of bucket for FFT analysis + let center = (bucket_start + bucket_end) / 2; + let fft_start = center.saturating_sub(fft_size / 2); + let fft_end = (fft_start + fft_size).min(audio_samples.len()); + let available = fft_end - fft_start; + + // Prepare FFT buffer with zero-padding if needed + let mut buffer: Vec> = (0..fft_size) + .map(|j| { + if j < available { + let sample = audio_samples[fft_start + j]; + Complex::new(sample * window[j], 0.0) + } else { + Complex::new(0.0, 0.0) + } + }) + .collect(); + + // Compute FFT + fft.process(&mut buffer); + + // Calculate energy in each frequency band (magnitude squared) + let mut low_energy = 0.0f32; + let mut mid_energy = 0.0f32; + let mut high_energy = 0.0f32; + + for (bin, c) in buffer.iter().enumerate().take(nyquist_bin) { + let mag_sq = c.norm_sqr(); + if bin < low_bin_end { + low_energy += mag_sq; + } else if bin < mid_bin_end { + mid_energy += mag_sq; + } else { + high_energy += mag_sq; + } + } + + // Normalize by band size to get average energy per bin + let low_bins = low_bin_end.max(1) as f32; + let mid_bins = (mid_bin_end - low_bin_end).max(1) as f32; + let high_bins = (nyquist_bin - mid_bin_end).max(1) as f32; + + low_energy = (low_energy / low_bins).sqrt(); + mid_energy = (mid_energy / mid_bins).sqrt(); + high_energy = (high_energy / high_bins).sqrt(); + + // Normalize to relative energy (CDJ/rekordbox style) + // This shows which frequency band dominates, not absolute energy + let total_energy = low_energy + mid_energy + high_energy; + if total_energy > 0.001 { + low_energy /= total_energy; + mid_energy /= total_energy; + high_energy /= total_energy; + } else { + // Silent section - show as dark gray + low_energy = 0.33; + mid_energy = 0.33; + high_energy = 0.33; + } + + frequency_bands.push(FrequencyBands::new(low_energy, mid_energy, high_energy)); + } + + TrackWaveform { + track_id, + samples: waveform_samples, + frequency_bands: Some(frequency_bands), + sample_count: target_samples, + duration_seconds, + } +} + +/// Stream waveform progress updates for UI responsiveness. +/// +/// Generates amplitude-only samples progressively and calls `on_progress` +/// after each chunk with the accumulated samples and progress (0.0 to 1.0). +/// This is used for UI updates during analysis; the final colored waveform +/// is generated separately by `generate_colored_waveform`. +fn stream_waveform_progress( + audio_samples: &[f32], + sample_rate: u32, + config: &AnalysisConfig, + chunk_size: usize, + mut on_progress: F, +) where + F: FnMut(Vec, f32), +{ + let duration_seconds = audio_samples.len() as f64 / sample_rate as f64; + + // Calculate target samples using same formula as generate_colored_waveform + let target_samples = + ((duration_seconds as f32 * config.waveform_samples_per_second).ceil() as usize).max(100); + + if audio_samples.is_empty() { + on_progress(vec![0.0; target_samples], 1.0); + return; + } + + let samples_per_bucket = audio_samples.len() / target_samples.max(1); + + let mut waveform_samples = Vec::with_capacity(target_samples); + + for i in 0..target_samples { + let start = i * samples_per_bucket; + let end = ((i + 1) * samples_per_bucket).min(audio_samples.len()); + + let peak = if start >= audio_samples.len() { + 0.0 + } else { + audio_samples[start..end] + .iter() + .map(|s| s.abs()) + .fold(0.0f32, f32::max) + }; + + waveform_samples.push(peak); + + // Send progress after each chunk + if (i + 1) % chunk_size == 0 || i == target_samples - 1 { + let progress = (i + 1) as f32 / target_samples as f32; + on_progress(waveform_samples.clone(), progress); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_colored_waveform() { + let samples: Vec = (0..44100).map(|i| (i as f32 * 0.01).sin()).collect(); + + let config = AnalysisConfig::default(); + let waveform = generate_colored_waveform(&samples, 44100, TrackId(1), &config); + + // 1 second of audio at 150 samples/second = 150 samples + let expected_samples = 150; + assert_eq!(waveform.sample_count, expected_samples); + assert_eq!(waveform.samples.len(), expected_samples); + assert!((waveform.duration_seconds - 1.0).abs() < 0.01); + // Verify frequency bands are generated + assert!(waveform.frequency_bands.is_some()); + assert_eq!( + waveform.frequency_bands.as_ref().unwrap().len(), + expected_samples + ); + } +} diff --git a/crates/dj/src/library/database.rs b/crates/dj/src/library/database.rs new file mode 100644 index 0000000..12b1a52 --- /dev/null +++ b/crates/dj/src/library/database.rs @@ -0,0 +1,691 @@ +//! SQLite database for the DJ library. + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Result as SqliteResult}; + +use super::types::{AudioFormat, BeatGrid, FrequencyBands, HotCue, Track, TrackId, TrackWaveform}; + +/// Database connection wrapper for the DJ library. +pub struct LibraryDatabase { + conn: Connection, +} + +impl LibraryDatabase { + /// Open or create a database at the given path. + pub fn open>(path: P) -> Result { + let conn = Connection::open(path)?; + + // Enable WAL mode for better concurrent access + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + + let db = Self { conn }; + db.create_tables()?; + + Ok(db) + } + + /// Open an in-memory database (for testing). + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + let db = Self { conn }; + db.create_tables()?; + Ok(db) + } + + /// Create the database tables if they don't exist. + fn create_tables(&self) -> SqliteResult<()> { + self.conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + artist TEXT, + album TEXT, + duration_seconds REAL NOT NULL, + bpm REAL, + musical_key TEXT, + format TEXT NOT NULL, + sample_rate INTEGER NOT NULL, + bit_depth INTEGER NOT NULL, + channels INTEGER NOT NULL, + file_size_bytes INTEGER NOT NULL, + date_added TEXT NOT NULL, + last_played TEXT, + play_count INTEGER DEFAULT 0, + rating INTEGER DEFAULT 0, + comment TEXT + ); + + CREATE TABLE IF NOT EXISTS beat_grids ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER NOT NULL UNIQUE, + bpm REAL NOT NULL, + first_beat_offset_ms REAL NOT NULL, + beat_positions BLOB, + confidence REAL NOT NULL, + analyzed_at TEXT NOT NULL, + algorithm_version TEXT NOT NULL, + FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS waveforms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER NOT NULL UNIQUE, + samples BLOB NOT NULL, + sample_count INTEGER NOT NULL, + duration_seconds REAL NOT NULL, + frequency_bands BLOB, + version INTEGER DEFAULT 1, + FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS hot_cues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER NOT NULL, + slot INTEGER NOT NULL, + position_seconds REAL NOT NULL, + name TEXT, + color_r INTEGER, + color_g INTEGER, + color_b INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE, + UNIQUE(track_id, slot) + ); + + CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist); + CREATE INDEX IF NOT EXISTS idx_tracks_bpm ON tracks(bpm); + CREATE INDEX IF NOT EXISTS idx_tracks_date_added ON tracks(date_added); + "#, + )?; + + // Run migrations for existing databases + self.run_migrations()?; + + Ok(()) + } + + /// Run database migrations for schema updates. + fn run_migrations(&self) -> SqliteResult<()> { + // Add frequency_bands column if it doesn't exist + let has_frequency_bands = self.conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('waveforms') WHERE name='frequency_bands'", + [], + |row| row.get::<_, i32>(0), + )? > 0; + + if !has_frequency_bands { + self.conn + .execute("ALTER TABLE waveforms ADD COLUMN frequency_bands BLOB", [])?; + } + + Ok(()) + } + + /// Insert a new track into the database. + pub fn insert_track(&self, track: &Track) -> SqliteResult { + self.conn.execute( + r#" + INSERT INTO tracks ( + file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) + "#, + params![ + track.file_path, + track.title, + track.artist, + track.album, + track.duration_seconds, + track.bpm, + track.key, + track.format.as_str(), + track.sample_rate, + track.bit_depth, + track.channels, + track.file_size_bytes, + track.date_added.to_rfc3339(), + track.last_played.map(|dt| dt.to_rfc3339()), + track.play_count, + track.rating, + track.comment, + ], + )?; + + Ok(TrackId(self.conn.last_insert_rowid())) + } + + /// Get a track by ID. + pub fn get_track(&self, id: TrackId) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks WHERE id = ?1 + "#, + )?; + + let mut rows = stmt.query(params![id.0])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_track(row)?)) + } else { + Ok(None) + } + } + + /// Get a track by file path. + pub fn get_track_by_path(&self, path: &str) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks WHERE file_path = ?1 + "#, + )?; + + let mut rows = stmt.query(params![path])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_track(row)?)) + } else { + Ok(None) + } + } + + /// Get all tracks in the library. + pub fn get_all_tracks(&self) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks ORDER BY date_added DESC + "#, + )?; + + let rows = stmt.query_map([], |row| Self::row_to_track(row))?; + + rows.collect() + } + + /// Get the adjacent track (next or previous) in the library. + /// + /// Tracks are ordered by title. If `next` is true, returns the track after + /// the given track_id. If `next` is false, returns the track before. + pub fn get_adjacent_track(&self, track_id: TrackId, next: bool) -> SqliteResult> { + // First, get the title of the current track + let current_title: Option = self + .conn + .query_row( + "SELECT title FROM tracks WHERE id = ?1", + params![track_id.0], + |row| row.get(0), + ) + .ok(); + + let Some(current_title) = current_title else { + return Ok(None); + }; + + // Query for adjacent track based on title ordering + let query = if next { + // Next track: title > current OR (title = current AND id > current) + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks + WHERE (title > ?1) OR (title = ?1 AND id > ?2) + ORDER BY title ASC, id ASC + LIMIT 1 + "# + } else { + // Previous track: title < current OR (title = current AND id < current) + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks + WHERE (title < ?1) OR (title = ?1 AND id < ?2) + ORDER BY title DESC, id DESC + LIMIT 1 + "# + }; + + let mut stmt = self.conn.prepare(query)?; + let mut rows = stmt.query(params![current_title, track_id.0])?; + + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_track(row)?)) + } else { + Ok(None) + } + } + + /// Search tracks by title or artist. + pub fn search_tracks(&self, query: &str) -> SqliteResult> { + let search_pattern = format!("%{}%", query); + let mut stmt = self.conn.prepare( + r#" + SELECT id, file_path, title, artist, album, duration_seconds, bpm, musical_key, + format, sample_rate, bit_depth, channels, file_size_bytes, + date_added, last_played, play_count, rating, comment + FROM tracks + WHERE title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1 + ORDER BY title + "#, + )?; + + let rows = stmt.query_map(params![search_pattern], |row| Self::row_to_track(row))?; + + rows.collect() + } + + /// Update the BPM for a track. + pub fn update_track_bpm(&self, id: TrackId, bpm: f64) -> SqliteResult<()> { + self.conn.execute( + "UPDATE tracks SET bpm = ?1 WHERE id = ?2", + params![bpm, id.0], + )?; + Ok(()) + } + + /// Update the play count and last played time. + pub fn update_track_played(&self, id: TrackId) -> SqliteResult<()> { + self.conn.execute( + r#" + UPDATE tracks + SET play_count = play_count + 1, last_played = ?1 + WHERE id = ?2 + "#, + params![Utc::now().to_rfc3339(), id.0], + )?; + Ok(()) + } + + /// Delete a track from the database. + pub fn delete_track(&self, id: TrackId) -> SqliteResult<()> { + self.conn + .execute("DELETE FROM tracks WHERE id = ?1", params![id.0])?; + Ok(()) + } + + /// Get the total number of tracks. + pub fn track_count(&self) -> SqliteResult { + let count: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM tracks", [], |row| row.get(0))?; + Ok(count as usize) + } + + // Beat grid operations + + /// Save a beat grid for a track. + /// + /// Note: BPM is stored in `tracks.bpm`, not in beat_grids. The bpm column + /// is kept for database schema backward compatibility but set to 0.0. + pub fn save_beat_grid(&self, beat_grid: &BeatGrid) -> SqliteResult<()> { + // Serialize beat positions as JSON blob + let positions_blob = serde_json::to_vec(&beat_grid.beat_positions).unwrap_or_default(); + + self.conn.execute( + r#" + INSERT OR REPLACE INTO beat_grids ( + track_id, bpm, first_beat_offset_ms, beat_positions, + confidence, analyzed_at, algorithm_version + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + "#, + params![ + beat_grid.track_id.0, + 0.0_f64, // BPM is stored in tracks.bpm, not here + beat_grid.first_beat_offset_ms, + positions_blob, + beat_grid.confidence, + beat_grid.analyzed_at.to_rfc3339(), + "current", // algorithm_version column kept for schema compatibility + ], + )?; + Ok(()) + } + + /// Get the beat grid for a track. + /// + /// Note: BPM is stored in `tracks.bpm`, not in beat_grids. Beat positions + /// should be recalculated from `first_beat_offset_ms` and `track.bpm` when loading. + pub fn get_beat_grid(&self, track_id: TrackId) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT track_id, first_beat_offset_ms, beat_positions, + confidence, analyzed_at + FROM beat_grids WHERE track_id = ?1 + "#, + )?; + + let mut rows = stmt.query(params![track_id.0])?; + + if let Some(row) = rows.next()? { + let positions_blob: Vec = row.get(2)?; + let beat_positions: Vec = + serde_json::from_slice(&positions_blob).unwrap_or_default(); + let analyzed_at_str: String = row.get(4)?; + + Ok(Some(BeatGrid { + track_id: TrackId(row.get(0)?), + first_beat_offset_ms: row.get(1)?, + beat_positions, + confidence: row.get(3)?, + analyzed_at: DateTime::parse_from_rfc3339(&analyzed_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + })) + } else { + Ok(None) + } + } + + /// Update the beat grid offset and positions. + pub fn update_beat_grid_offset( + &self, + track_id: TrackId, + new_offset_ms: f64, + new_beat_positions: &[f64], + ) -> SqliteResult<()> { + let positions_blob = serde_json::to_vec(new_beat_positions).unwrap_or_default(); + + self.conn.execute( + "UPDATE beat_grids SET first_beat_offset_ms = ?1, beat_positions = ?2 WHERE track_id = ?3", + params![new_offset_ms, positions_blob, track_id.0], + )?; + Ok(()) + } + + // Hot cue operations + + /// Save a hot cue. + pub fn save_hot_cue(&self, hot_cue: &HotCue) -> SqliteResult { + let (color_r, color_g, color_b) = hot_cue.color.unwrap_or((255, 255, 255)); + + self.conn.execute( + r#" + INSERT OR REPLACE INTO hot_cues ( + track_id, slot, position_seconds, name, + color_r, color_g, color_b, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "#, + params![ + hot_cue.track_id.0, + hot_cue.slot, + hot_cue.position_seconds, + hot_cue.name, + color_r, + color_g, + color_b, + hot_cue.created_at.to_rfc3339(), + ], + )?; + + Ok(self.conn.last_insert_rowid()) + } + + /// Get all hot cues for a track. + pub fn get_hot_cues(&self, track_id: TrackId) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT id, track_id, slot, position_seconds, name, + color_r, color_g, color_b, created_at + FROM hot_cues WHERE track_id = ?1 ORDER BY slot + "#, + )?; + + let rows = stmt.query_map(params![track_id.0], |row| { + let created_at_str: String = row.get(8)?; + let color_r: Option = row.get(5)?; + let color_g: Option = row.get(6)?; + let color_b: Option = row.get(7)?; + + Ok(HotCue { + id: row.get(0)?, + track_id: TrackId(row.get(1)?), + slot: row.get(2)?, + position_seconds: row.get(3)?, + name: row.get(4)?, + color: color_r + .zip(color_g) + .zip(color_b) + .map(|((r, g), b)| (r, g, b)), + created_at: DateTime::parse_from_rfc3339(&created_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + }) + })?; + + rows.collect() + } + + /// Delete a hot cue. + pub fn delete_hot_cue(&self, track_id: TrackId, slot: u8) -> SqliteResult<()> { + self.conn.execute( + "DELETE FROM hot_cues WHERE track_id = ?1 AND slot = ?2", + params![track_id.0, slot], + )?; + Ok(()) + } + + // Waveform operations + + /// Save a waveform. + pub fn save_waveform(&self, waveform: &TrackWaveform) -> SqliteResult<()> { + // Convert f32 samples to bytes + let samples_bytes: Vec = waveform + .samples + .iter() + .flat_map(|s| s.to_le_bytes()) + .collect(); + + // Convert frequency bands to bytes (12 bytes per sample: 3 x f32) + let frequency_bands_bytes: Option> = + waveform.frequency_bands.as_ref().map(|bands| { + bands + .iter() + .flat_map(|fb| { + let mut bytes = Vec::with_capacity(12); + bytes.extend_from_slice(&fb.low.to_le_bytes()); + bytes.extend_from_slice(&fb.mid.to_le_bytes()); + bytes.extend_from_slice(&fb.high.to_le_bytes()); + bytes + }) + .collect() + }); + + self.conn.execute( + r#" + INSERT OR REPLACE INTO waveforms ( + track_id, samples, sample_count, duration_seconds, frequency_bands + ) VALUES (?1, ?2, ?3, ?4, ?5) + "#, + params![ + waveform.track_id.0, + samples_bytes, + waveform.sample_count, + waveform.duration_seconds, + frequency_bands_bytes, + ], + )?; + Ok(()) + } + + /// Get the waveform for a track. + pub fn get_waveform(&self, track_id: TrackId) -> SqliteResult> { + let mut stmt = self.conn.prepare( + r#" + SELECT track_id, samples, sample_count, duration_seconds, frequency_bands + FROM waveforms WHERE track_id = ?1 + "#, + )?; + + let mut rows = stmt.query(params![track_id.0])?; + + if let Some(row) = rows.next()? { + let samples_bytes: Vec = row.get(1)?; + let samples: Vec = samples_bytes + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + + // Deserialize frequency bands (12 bytes per sample: 3 x f32) + let frequency_bands_bytes: Option> = row.get(4)?; + let frequency_bands = frequency_bands_bytes.map(|bytes| { + bytes + .chunks_exact(12) + .map(|chunk| { + let low = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let mid = f32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]); + let high = f32::from_le_bytes([chunk[8], chunk[9], chunk[10], chunk[11]]); + FrequencyBands::new(low, mid, high) + }) + .collect() + }); + + Ok(Some(TrackWaveform { + track_id: TrackId(row.get(0)?), + samples, + frequency_bands, + sample_count: row.get(2)?, + duration_seconds: row.get(3)?, + })) + } else { + Ok(None) + } + } + + /// Convert a database row to a Track. + fn row_to_track(row: &rusqlite::Row) -> SqliteResult { + let format_str: String = row.get(8)?; + let date_added_str: String = row.get(13)?; + let last_played_str: Option = row.get(14)?; + + Ok(Track { + id: TrackId(row.get(0)?), + file_path: row.get(1)?, + title: row.get(2)?, + artist: row.get(3)?, + album: row.get(4)?, + duration_seconds: row.get(5)?, + bpm: row.get(6)?, + key: row.get(7)?, + format: AudioFormat::from_extension(&format_str), + sample_rate: row.get(9)?, + bit_depth: row.get(10)?, + channels: row.get(11)?, + file_size_bytes: row.get(12)?, + date_added: DateTime::parse_from_rfc3339(&date_added_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + last_played: last_played_str.and_then(|s| { + DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + }), + play_count: row.get(15)?, + rating: row.get(16)?, + comment: row.get(17)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_database() { + let db = LibraryDatabase::open_in_memory().unwrap(); + assert_eq!(db.track_count().unwrap(), 0); + } + + #[test] + fn test_insert_and_get_track() { + let db = LibraryDatabase::open_in_memory().unwrap(); + + let track = Track::new( + TrackId(0), + "/path/to/song.mp3".to_string(), + "Test Song".to_string(), + 120.0, + AudioFormat::Mp3, + 44100, + ); + + let id = db.insert_track(&track).unwrap(); + assert_eq!(id.0, 1); + + let loaded = db.get_track(id).unwrap().unwrap(); + assert_eq!(loaded.title, "Test Song"); + assert_eq!(loaded.file_path, "/path/to/song.mp3"); + } + + #[test] + fn test_search_tracks() { + let db = LibraryDatabase::open_in_memory().unwrap(); + + let mut track1 = Track::new( + TrackId(0), + "/path/to/song1.mp3".to_string(), + "Hello World".to_string(), + 120.0, + AudioFormat::Mp3, + 44100, + ); + track1.artist = Some("Test Artist".to_string()); + + let mut track2 = Track::new( + TrackId(0), + "/path/to/song2.mp3".to_string(), + "Goodbye".to_string(), + 130.0, + AudioFormat::Mp3, + 44100, + ); + track2.artist = Some("Other Artist".to_string()); + + db.insert_track(&track1).unwrap(); + db.insert_track(&track2).unwrap(); + + let results = db.search_tracks("Hello").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Hello World"); + + let results = db.search_tracks("Artist").unwrap(); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_hot_cues() { + let db = LibraryDatabase::open_in_memory().unwrap(); + + let track = Track::new( + TrackId(0), + "/path/to/song.mp3".to_string(), + "Test".to_string(), + 180.0, + AudioFormat::Mp3, + 44100, + ); + let track_id = db.insert_track(&track).unwrap(); + + let cue = HotCue::new(track_id, 0, 30.5); + db.save_hot_cue(&cue).unwrap(); + + let cues = db.get_hot_cues(track_id).unwrap(); + assert_eq!(cues.len(), 1); + assert_eq!(cues[0].slot, 0); + assert!((cues[0].position_seconds - 30.5).abs() < 0.001); + } +} diff --git a/crates/dj/src/library/import.rs b/crates/dj/src/library/import.rs new file mode 100644 index 0000000..cc7488d --- /dev/null +++ b/crates/dj/src/library/import.rs @@ -0,0 +1,401 @@ +//! Audio file import and metadata extraction. + +use std::fs::{self, File}; +use std::path::Path; + +use symphonia::core::codecs::CODEC_TYPE_NULL; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +use super::analysis::{analyze_file, AnalysisConfig, AnalysisResult}; +use super::database::LibraryDatabase; +use super::types::{AudioFormat, Track, TrackId}; + +/// Result of importing and analyzing a track. +#[derive(Debug)] +pub struct ImportResult { + /// The imported track with database ID. + pub track: Track, + /// Analysis result (if analysis was performed). + pub analysis: Option, +} + +/// Import a file and add it to the database without analysis. +/// +/// This function only extracts metadata and inserts the track into the database. +/// Analysis should be run separately via analyze_file() for background processing. +/// Returns the track with its database ID. +pub fn import_file_metadata_only>( + path: P, + db: &LibraryDatabase, +) -> Result { + let path = path.as_ref(); + + // Check if track already exists in database + let path_str = path.to_string_lossy().to_string(); + if let Some(existing) = db.get_track_by_path(&path_str)? { + log::info!("Track already in library: {:?}", path); + return Ok(existing); + } + + // Import file metadata + let track = import_file(path)?; + + // Insert into database (without BPM - will be set after analysis) + let track_id = db.insert_track(&track)?; + log::info!("Inserted track with ID: {} (pending analysis)", track_id); + + // Get the track back with the correct ID + let track = db + .get_track(track_id)? + .ok_or_else(|| anyhow::anyhow!("Failed to retrieve inserted track"))?; + + Ok(track) +} + +/// Scan a directory for audio files without importing them. +/// Returns a list of paths to supported audio files. +pub fn scan_directory_for_audio>( + path: P, + recursive: bool, +) -> Vec { + let path = path.as_ref(); + let mut files = Vec::new(); + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + + if entry_path.is_dir() { + if recursive { + files.extend(scan_directory_for_audio(&entry_path, true)); + } + } else if is_supported_audio_file(&entry_path) { + files.push(entry_path); + } + } + } + + files +} + +/// Import a file, add it to the database, and optionally analyze it. +/// +/// This is the primary function for adding new tracks to the library. +/// It handles: +/// 1. Extracting metadata from the audio file +/// 2. Inserting the track into the database +/// 3. Running BPM/beat-grid analysis +/// 4. Storing analysis results (beat grid, waveform) +/// 5. Updating the track's BPM from analysis +pub fn import_and_analyze_file>( + path: P, + db: &LibraryDatabase, + run_analysis: bool, +) -> Result { + let path = path.as_ref(); + + // Check if track already exists in database + let path_str = path.to_string_lossy().to_string(); + if let Some(existing) = db.get_track_by_path(&path_str)? { + log::info!("Track already in library: {:?}", path); + // Get existing analysis if available + let beat_grid = db.get_beat_grid(existing.id)?; + let waveform = db.get_waveform(existing.id)?; + let analysis = beat_grid.map(|bg| AnalysisResult { + bpm: existing.bpm.unwrap_or(120.0), + beat_grid: bg, + waveform: waveform.unwrap_or_else(|| super::types::TrackWaveform { + track_id: existing.id, + samples: vec![], + frequency_bands: None, + sample_count: 0, + duration_seconds: existing.duration_seconds, + }), + }); + return Ok(ImportResult { + track: existing, + analysis, + }); + } + + // Import file metadata + let track = import_file(path)?; + + // Insert into database + let track_id = db.insert_track(&track)?; + log::info!("Inserted track with ID: {}", track_id); + + // Get the track back with the correct ID + let mut track = db + .get_track(track_id)? + .ok_or_else(|| anyhow::anyhow!("Failed to retrieve inserted track"))?; + + // Run analysis if requested + let analysis = if run_analysis { + log::info!("Running analysis on track: {}", track.title); + let config = AnalysisConfig::default(); + + match analyze_file(path, track_id, &config) { + Ok(result) => { + // Save beat grid to database + if let Err(e) = db.save_beat_grid(&result.beat_grid) { + log::warn!("Failed to save beat grid: {}", e); + } + + // Save waveform to database + if let Err(e) = db.save_waveform(&result.waveform) { + log::warn!("Failed to save waveform: {}", e); + } + + // Update track BPM from analysis + if let Err(e) = db.update_track_bpm(track_id, result.bpm) { + log::warn!("Failed to update track BPM: {}", e); + } else { + track.bpm = Some(result.bpm); + } + + log::info!( + "Analysis complete: BPM={:.1} (confidence={:.2})", + result.bpm, + result.beat_grid.confidence + ); + + Some(result) + } + Err(e) => { + log::warn!("Analysis failed for {}: {}", track.title, e); + None + } + } + } else { + None + }; + + Ok(ImportResult { track, analysis }) +} + +/// Import and analyze all audio files from a directory. +pub fn import_and_analyze_directory>( + path: P, + db: &LibraryDatabase, + run_analysis: bool, + recursive: bool, +) -> Vec> { + let path = path.as_ref(); + log::info!( + "Importing directory: {:?} (recursive: {}, analyze: {})", + path, + recursive, + run_analysis + ); + + let mut results = Vec::new(); + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + + if entry_path.is_dir() { + if recursive { + results.extend(import_and_analyze_directory( + &entry_path, + db, + run_analysis, + true, + )); + } + } else if is_supported_audio_file(&entry_path) { + results.push(import_and_analyze_file(&entry_path, db, run_analysis)); + } + } + } + + let success_count = results.iter().filter(|r| r.is_ok()).count(); + log::info!( + "Imported {} of {} files from {:?}", + success_count, + results.len(), + path + ); + results +} + +/// Import a single audio file and extract metadata. +pub fn import_file>(path: P) -> Result { + let path = path.as_ref(); + log::info!("Importing file: {:?}", path); + + // Get file metadata + let metadata = fs::metadata(path)?; + let file_size = metadata.len(); + + // Determine format from extension + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let format = AudioFormat::from_extension(extension); + + if format == AudioFormat::Unknown { + return Err(anyhow::anyhow!("Unsupported audio format: {}", extension)); + } + + // Get title from filename + let file_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string(); + + // Open and probe the file + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + hint.with_extension(extension); + + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + + let mut format_reader = probed.format; + + // Find the first audio track + let track_info = format_reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow::anyhow!("No audio track found"))?; + + let codec_params = &track_info.codec_params; + + // Extract audio parameters + let sample_rate = codec_params.sample_rate.unwrap_or(44100); + let channels = codec_params.channels.map(|c| c.count()).unwrap_or(2) as u8; + let bit_depth = codec_params.bits_per_sample.unwrap_or(16) as u16; + let n_frames = codec_params.n_frames.unwrap_or(0); + + // Calculate duration + let duration_seconds = n_frames as f64 / sample_rate as f64; + + // Extract metadata (title, artist, album) + let mut title = file_name; + let mut artist = None; + let mut album = None; + + // Check for metadata in the format + if let Some(metadata) = format_reader.metadata().current() { + for tag in metadata.tags() { + match tag.std_key { + Some(symphonia::core::meta::StandardTagKey::TrackTitle) => { + title = tag.value.to_string(); + } + Some(symphonia::core::meta::StandardTagKey::Artist) => { + artist = Some(tag.value.to_string()); + } + Some(symphonia::core::meta::StandardTagKey::Album) => { + album = Some(tag.value.to_string()); + } + _ => {} + } + } + } + + let mut track = Track::new( + TrackId(0), // Will be set by database + path.to_string_lossy().to_string(), + title, + duration_seconds, + format, + sample_rate, + ); + + track.artist = artist; + track.album = album; + track.bit_depth = bit_depth; + track.channels = channels; + track.file_size_bytes = file_size; + + log::info!( + "Imported: {} by {:?} ({:.1}s, {} Hz)", + track.title, + track.artist, + track.duration_seconds, + track.sample_rate + ); + + Ok(track) +} + +/// Import all audio files from a directory. +pub fn import_directory>( + path: P, + recursive: bool, +) -> Vec> { + let path = path.as_ref(); + log::info!("Importing directory: {:?} (recursive: {})", path, recursive); + + let mut results = Vec::new(); + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + + if entry_path.is_dir() { + if recursive { + results.extend(import_directory(&entry_path, true)); + } + } else if is_supported_audio_file(&entry_path) { + results.push(import_file(&entry_path)); + } + } + } + + log::info!("Imported {} files from {:?}", results.len(), path); + results +} + +/// Check if a file is a supported audio format. +pub fn is_supported_audio_file(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| { + matches!( + ext.to_lowercase().as_str(), + "mp3" | "wav" | "aiff" | "aif" | "flac" | "m4a" | "aac" | "ogg" + ) + }) + .unwrap_or(false) +} + +/// Supported audio file extensions. +pub fn supported_extensions() -> &'static [&'static str] { + &["mp3", "wav", "aiff", "aif", "flac", "m4a", "aac", "ogg"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_supported_audio_file() { + assert!(is_supported_audio_file(Path::new("/path/to/song.mp3"))); + assert!(is_supported_audio_file(Path::new("/path/to/song.MP3"))); + assert!(is_supported_audio_file(Path::new("/path/to/song.wav"))); + assert!(is_supported_audio_file(Path::new("/path/to/song.aiff"))); + assert!(is_supported_audio_file(Path::new("/path/to/song.aif"))); + assert!(!is_supported_audio_file(Path::new("/path/to/song.txt"))); + assert!(!is_supported_audio_file(Path::new("/path/to/song"))); + } + + #[test] + fn test_audio_format_from_extension() { + assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3); + assert_eq!(AudioFormat::from_extension("wav"), AudioFormat::Wav); + assert_eq!(AudioFormat::from_extension("aiff"), AudioFormat::Aiff); + assert_eq!(AudioFormat::from_extension("xyz"), AudioFormat::Unknown); + } +} diff --git a/crates/dj/src/library/mod.rs b/crates/dj/src/library/mod.rs new file mode 100644 index 0000000..ab102e5 --- /dev/null +++ b/crates/dj/src/library/mod.rs @@ -0,0 +1,20 @@ +//! Library module for track management, analysis, and database operations. + +mod types; + +pub mod analysis; +pub mod database; +pub mod import; +pub mod qm_native; + +pub use analysis::{analyze_file, analyze_file_streaming, AnalysisConfig, AnalysisResult}; +pub use database::LibraryDatabase; +pub use import::{ + import_and_analyze_directory, import_and_analyze_file, import_directory, import_file, + is_supported_audio_file, supported_extensions, ImportResult, +}; +pub use qm_native::{median_tempo, NativeTempoTracker}; +pub use types::{ + AudioFormat, BeatGrid, FrequencyBands, HotCue, MasterTempoMode, TempoRange, Track, TrackId, + TrackWaveform, +}; diff --git a/crates/dj/src/library/qm_native.rs b/crates/dj/src/library/qm_native.rs new file mode 100644 index 0000000..7a30d67 --- /dev/null +++ b/crates/dj/src/library/qm_native.rs @@ -0,0 +1,201 @@ +//! Native QM-DSP TempoTrackV2 bindings via FFI. +//! +//! This module provides a safe Rust wrapper around the vendored QM-DSP C++ library +//! for accurate BPM detection. + +use std::ffi::c_int; + +/// Opaque handle to QmTempoTracker C++ object +#[repr(C)] +pub struct QmTempoTrackerHandle { + _private: [u8; 0], +} + +extern "C" { + fn qm_tempo_new(sample_rate: f32, df_increment: c_int) -> *mut QmTempoTrackerHandle; + fn qm_tempo_free(tracker: *mut QmTempoTrackerHandle); + fn qm_tempo_calculate_beat_period( + tracker: *mut QmTempoTrackerHandle, + df: *const f64, + df_len: c_int, + beat_periods: *mut f64, + tempi: *mut f64, + out_len: *mut c_int, + ) -> c_int; + fn qm_tempo_calculate_beats( + tracker: *mut QmTempoTrackerHandle, + df: *const f64, + df_len: c_int, + beat_periods: *const f64, + bp_len: c_int, + beats: *mut f64, + beats_len: *mut c_int, + ) -> c_int; +} + +/// Safe Rust wrapper around QM-DSP TempoTrackV2. +/// +/// # Example +/// ```ignore +/// let mut tracker = NativeTempoTracker::new(44100.0, 512); +/// let (beat_periods, tempi) = tracker.calculate_beat_period(&detection_function); +/// let median_bpm = tempi.iter().sum::() / tempi.len() as f64; +/// ``` +pub struct NativeTempoTracker { + handle: *mut QmTempoTrackerHandle, +} + +impl NativeTempoTracker { + /// Create a new tempo tracker. + /// + /// # Arguments + /// * `sample_rate` - Audio sample rate (e.g., 44100.0) + /// * `df_increment` - Detection function frame increment (e.g., 512) + /// + /// # Panics + /// Panics if the C++ tracker creation fails (out of memory). + pub fn new(sample_rate: f32, df_increment: i32) -> Self { + let handle = unsafe { qm_tempo_new(sample_rate, df_increment) }; + assert!(!handle.is_null(), "Failed to create QM-DSP tempo tracker"); + Self { handle } + } + + /// Calculate beat periods and tempi from a detection function. + /// + /// The detection function should be computed from audio samples using + /// an onset detection algorithm. + /// + /// # Returns + /// A tuple of (beat_periods, tempi) where: + /// - beat_periods: Beat period in detection function frames + /// - tempi: Tempo in BPM at each frame + pub fn calculate_beat_period(&mut self, df: &[f64]) -> (Vec, Vec) { + if df.is_empty() { + return (Vec::new(), Vec::new()); + } + + let mut beat_periods = vec![0.0; df.len()]; + let mut tempi = vec![0.0; df.len()]; + let mut out_len: c_int = 0; + + let result = unsafe { + qm_tempo_calculate_beat_period( + self.handle, + df.as_ptr(), + df.len() as c_int, + beat_periods.as_mut_ptr(), + tempi.as_mut_ptr(), + &mut out_len, + ) + }; + + if result != 0 { + log::error!("qm_tempo_calculate_beat_period failed"); + return (Vec::new(), Vec::new()); + } + + beat_periods.truncate(out_len as usize); + tempi.truncate(out_len as usize); + (beat_periods, tempi) + } + + /// Calculate beat positions from detection function and beat periods. + /// + /// # Returns + /// Beat positions in detection function frame units. + pub fn calculate_beats(&mut self, df: &[f64], beat_periods: &[f64]) -> Vec { + if df.is_empty() || beat_periods.is_empty() { + return Vec::new(); + } + + let mut beats = vec![0.0; df.len()]; + let mut beats_len: c_int = 0; + + let result = unsafe { + qm_tempo_calculate_beats( + self.handle, + df.as_ptr(), + df.len() as c_int, + beat_periods.as_ptr(), + beat_periods.len() as c_int, + beats.as_mut_ptr(), + &mut beats_len, + ) + }; + + if result != 0 { + log::error!("qm_tempo_calculate_beats failed"); + return Vec::new(); + } + + beats.truncate(beats_len as usize); + beats + } + + /// Calculate both beat periods and beat positions in one call. + /// + /// This is a convenience method that calls `calculate_beat_period` and + /// `calculate_beats` in sequence. + /// + /// # Returns + /// A tuple of (beat_periods, tempi, beat_positions) + pub fn analyze(&mut self, df: &[f64]) -> (Vec, Vec, Vec) { + let (beat_periods, tempi) = self.calculate_beat_period(df); + let beats = self.calculate_beats(df, &beat_periods); + (beat_periods, tempi, beats) + } +} + +impl Drop for NativeTempoTracker { + fn drop(&mut self) { + unsafe { qm_tempo_free(self.handle) } + } +} + +// SAFETY: The C++ TempoTrackV2 object is not shared between threads +// and our wrapper provides exclusive access through &mut self. +unsafe impl Send for NativeTempoTracker {} + +/// Compute median tempo from a tempi array. +/// +/// This is the standard way to extract a single BPM value from the +/// per-frame tempo estimates. +pub fn median_tempo(tempi: &[f64]) -> f64 { + if tempi.is_empty() { + return 120.0; // Default fallback + } + + let mut sorted: Vec = tempi.iter().copied().filter(|&t| t > 0.0).collect(); + if sorted.is_empty() { + return 120.0; + } + + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + sorted[sorted.len() / 2] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tracker_creation() { + let tracker = NativeTempoTracker::new(44100.0, 512); + drop(tracker); + } + + #[test] + fn test_empty_input() { + let mut tracker = NativeTempoTracker::new(44100.0, 512); + let (bp, tempi) = tracker.calculate_beat_period(&[]); + assert!(bp.is_empty()); + assert!(tempi.is_empty()); + } + + #[test] + fn test_median_tempo() { + assert_eq!(median_tempo(&[]), 120.0); + assert_eq!(median_tempo(&[100.0]), 100.0); + assert_eq!(median_tempo(&[100.0, 120.0, 140.0]), 120.0); + } +} diff --git a/crates/dj/src/library/types.rs b/crates/dj/src/library/types.rs new file mode 100644 index 0000000..887c5b5 --- /dev/null +++ b/crates/dj/src/library/types.rs @@ -0,0 +1,499 @@ +//! Core library types for the DJ module. + +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Unique identifier for a track in the library. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrackId(pub i64); + +impl From for TrackId { + fn from(id: i64) -> Self { + Self(id) + } +} + +impl From for i64 { + fn from(id: TrackId) -> Self { + id.0 + } +} + +impl fmt::Display for TrackId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Supported audio formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AudioFormat { + Mp3, + Wav, + Aiff, + Flac, + Aac, + Ogg, + Unknown, +} + +impl AudioFormat { + /// Determine format from file extension. + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { + "mp3" => Self::Mp3, + "wav" => Self::Wav, + "aiff" | "aif" => Self::Aiff, + "flac" => Self::Flac, + "aac" | "m4a" => Self::Aac, + "ogg" => Self::Ogg, + _ => Self::Unknown, + } + } + + /// Get the format as a string. + pub fn as_str(&self) -> &'static str { + match self { + Self::Mp3 => "mp3", + Self::Wav => "wav", + Self::Aiff => "aiff", + Self::Flac => "flac", + Self::Aac => "aac", + Self::Ogg => "ogg", + Self::Unknown => "unknown", + } + } +} + +/// Audio track metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + /// Unique database ID. + pub id: TrackId, + /// Absolute path to the audio file. + pub file_path: String, + /// Track title. + pub title: String, + /// Artist name. + pub artist: Option, + /// Album name. + pub album: Option, + /// Track duration in seconds. + pub duration_seconds: f64, + /// Detected BPM (None if not analyzed). + pub bpm: Option, + /// Musical key (e.g., "Am", "C#"). + pub key: Option, + /// Audio format. + pub format: AudioFormat, + /// Sample rate in Hz. + pub sample_rate: u32, + /// Bit depth (e.g., 16, 24). + pub bit_depth: u16, + /// Number of audio channels. + pub channels: u8, + /// File size in bytes. + pub file_size_bytes: u64, + /// When the track was added to the library. + pub date_added: DateTime, + /// When the track was last played. + pub last_played: Option>, + /// Number of times the track has been played. + pub play_count: u32, + /// User rating (0-5 stars). + pub rating: u8, + /// User comment. + pub comment: Option, +} + +impl Track { + /// Create a new track with required fields. + pub fn new( + id: TrackId, + file_path: String, + title: String, + duration_seconds: f64, + format: AudioFormat, + sample_rate: u32, + ) -> Self { + Self { + id, + file_path, + title, + artist: None, + album: None, + duration_seconds, + bpm: None, + key: None, + format, + sample_rate, + bit_depth: 16, + channels: 2, + file_size_bytes: 0, + date_added: Utc::now(), + last_played: None, + play_count: 0, + rating: 0, + comment: None, + } + } + + /// Get a display string for the track (Artist - Title). + pub fn display_name(&self) -> String { + match &self.artist { + Some(artist) => format!("{} - {}", artist, self.title), + None => self.title.clone(), + } + } +} + +/// Beat grid analysis data. +/// +/// Note: BPM is stored in `Track.bpm`, not here. Beat positions are calculated +/// on-the-fly from `first_beat_offset_ms` and the track's BPM when loading. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BeatGrid { + /// ID of the track this beat grid belongs to. + pub track_id: TrackId, + /// Time offset to the first beat in milliseconds. + pub first_beat_offset_ms: f64, + /// Beat positions in seconds (calculated from first_beat_offset_ms and track BPM). + pub beat_positions: Vec, + /// Analysis confidence (0.0-1.0). + pub confidence: f32, + /// When the analysis was performed. + pub analyzed_at: DateTime, +} + +impl BeatGrid { + /// Get the beat interval in seconds for a given BPM. + pub fn beat_interval_seconds(bpm: f64) -> f64 { + 60.0 / bpm + } + + /// Get the beat number at a given position for a given BPM. + pub fn beat_at_position(&self, position_seconds: f64, bpm: f64) -> f64 { + let offset_seconds = self.first_beat_offset_ms / 1000.0; + (position_seconds - offset_seconds) / Self::beat_interval_seconds(bpm) + } + + /// Get the phase (0.0-1.0) within the current beat for a given BPM. + pub fn beat_phase_at_position(&self, position_seconds: f64, bpm: f64) -> f64 { + let beat = self.beat_at_position(position_seconds, bpm); + beat - beat.floor() + } + + /// Get the bar phase (0.0-1.0) assuming 4/4 time for a given BPM. + pub fn bar_phase_at_position(&self, position_seconds: f64, bpm: f64) -> f64 { + let beat = self.beat_at_position(position_seconds, bpm); + let bar = beat / 4.0; + bar - bar.floor() + } + + /// Get the phrase phase (0.0-1.0) assuming 8-bar phrases for a given BPM. + pub fn phrase_phase_at_position(&self, position_seconds: f64, bpm: f64) -> f64 { + let beat = self.beat_at_position(position_seconds, bpm); + let phrase = beat / 32.0; // 8 bars * 4 beats + phrase - phrase.floor() + } + + /// Find the nearest beat position to the given time (seconds) for a given BPM. + /// + /// Returns the time in seconds of the beat closest to the given position. + /// Used for quantizing loop IN points to beat boundaries. + pub fn nearest_beat(&self, position_seconds: f64, bpm: f64) -> f64 { + let beat_number = self.beat_at_position(position_seconds, bpm); + let quantized_beat = beat_number.round(); + let offset_seconds = self.first_beat_offset_ms / 1000.0; + offset_seconds + (quantized_beat * Self::beat_interval_seconds(bpm)) + } + + /// Get the position N beats after a given position (seconds) for a given BPM. + /// + /// Used for calculating loop OUT points from loop IN. + pub fn beat_position_after(position_seconds: f64, beat_count: f64, bpm: f64) -> f64 { + position_seconds + (beat_count * Self::beat_interval_seconds(bpm)) + } +} + +/// 3-band frequency data for colored waveform visualization. +/// +/// Each band represents the energy in a frequency range: +/// - Low: 20-250 Hz (bass, kick drums) +/// - Mid: 250-4000 Hz (vocals, instruments) +/// - High: 4000-20000 Hz (hi-hats, cymbals) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct FrequencyBands { + /// Low frequency energy (0.0-1.0) - bass, kick drums. + pub low: f32, + /// Mid frequency energy (0.0-1.0) - vocals, instruments. + pub mid: f32, + /// High frequency energy (0.0-1.0) - hi-hats, cymbals. + pub high: f32, +} + +impl FrequencyBands { + /// Create new frequency bands. + pub fn new(low: f32, mid: f32, high: f32) -> Self { + Self { low, mid, high } + } + + /// Convert to RGB color (Red=low, Green=mid, Blue=high). + pub fn to_rgb(&self) -> (u8, u8, u8) { + // Scale and clamp values for better visibility + let r = (self.low.clamp(0.0, 1.0) * 255.0) as u8; + let g = (self.mid.clamp(0.0, 1.0) * 255.0) as u8; + let b = (self.high.clamp(0.0, 1.0) * 255.0) as u8; + (r, g, b) + } + + /// Convert to tuple for serialization. + pub fn as_tuple(&self) -> (f32, f32, f32) { + (self.low, self.mid, self.high) + } + + /// Create from tuple. + pub fn from_tuple(t: (f32, f32, f32)) -> Self { + Self { + low: t.0, + mid: t.1, + high: t.2, + } + } +} + +/// Waveform data for UI visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackWaveform { + /// ID of the track this waveform belongs to. + pub track_id: TrackId, + /// Downsampled waveform peaks (absolute values, 0.0-1.0). + pub samples: Vec, + /// 3-band frequency data for coloring (parallel to samples). + pub frequency_bands: Option>, + /// Number of samples in the waveform. + pub sample_count: usize, + /// Duration of the track in seconds. + pub duration_seconds: f64, +} + +impl TrackWaveform { + /// Get the sample index for a given position in seconds. + pub fn sample_at_position(&self, position_seconds: f64) -> usize { + let ratio = position_seconds / self.duration_seconds; + ((ratio * self.sample_count as f64) as usize).min(self.sample_count.saturating_sub(1)) + } + + /// Get a slice of samples for a time range. + pub fn samples_in_range(&self, start_seconds: f64, end_seconds: f64) -> &[f32] { + let start_idx = self.sample_at_position(start_seconds); + let end_idx = self.sample_at_position(end_seconds); + &self.samples[start_idx..=end_idx.min(self.samples.len().saturating_sub(1))] + } +} + +/// Hot cue point. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HotCue { + /// Database ID. + pub id: i64, + /// ID of the track this hot cue belongs to. + pub track_id: TrackId, + /// Slot number (0-3 for 4 hot cues). + pub slot: u8, + /// Position in seconds. + pub position_seconds: f64, + /// Optional name for the cue. + pub name: Option, + /// Optional RGB color. + pub color: Option<(u8, u8, u8)>, + /// When the cue was created. + pub created_at: DateTime, +} + +impl HotCue { + /// Create a new hot cue. + pub fn new(track_id: TrackId, slot: u8, position_seconds: f64) -> Self { + Self { + id: 0, // Will be set by database + track_id, + slot, + position_seconds, + name: None, + color: None, + created_at: Utc::now(), + } + } + + /// Get the default color for a slot. + pub fn default_color_for_slot(slot: u8) -> (u8, u8, u8) { + match slot { + 0 => (255, 0, 0), // Red + 1 => (0, 255, 0), // Green + 2 => (0, 0, 255), // Blue + 3 => (255, 255, 0), // Yellow + _ => (255, 255, 255), + } + } +} + +/// Master Tempo (key lock) mode. +/// +/// When enabled, tempo changes via pitch fader don't affect the audio pitch. +/// Uses time-stretching (WSOLA algorithm) to decouple tempo from pitch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum MasterTempoMode { + /// Varispeed mode - pitch changes with tempo (default, lowest latency). + #[default] + Off, + /// Master Tempo - pitch locked, tempo changes via time-stretching. + On, +} + +/// Tempo adjustment range preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TempoRange { + /// +/- 6% + Range6, + /// +/- 10% + #[default] + Range10, + /// +/- 16% + Range16, + /// +/- 100% (wide - full range, allows near-stop to double speed) + Wide, +} + +impl TempoRange { + /// Get the range as a percentage (e.g., 0.10 for +/- 10%). + pub fn as_fraction(&self) -> f64 { + match self { + Self::Range6 => 0.06, + Self::Range10 => 0.10, + Self::Range16 => 0.16, + Self::Wide => 1.00, + } + } + + /// Convert a pitch fader value (-1.0 to 1.0) to a tempo multiplier. + pub fn pitch_to_multiplier(&self, pitch: f64) -> f64 { + 1.0 + (pitch * self.as_fraction()) + } + + /// Convert to u8 for UI/event serialization. + pub fn to_u8(&self) -> u8 { + match self { + Self::Range6 => 0, + Self::Range10 => 1, + Self::Range16 => 2, + Self::Wide => 3, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_format_from_extension() { + assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3); + assert_eq!(AudioFormat::from_extension("MP3"), AudioFormat::Mp3); + assert_eq!(AudioFormat::from_extension("wav"), AudioFormat::Wav); + assert_eq!(AudioFormat::from_extension("aiff"), AudioFormat::Aiff); + assert_eq!(AudioFormat::from_extension("aif"), AudioFormat::Aiff); + assert_eq!(AudioFormat::from_extension("xyz"), AudioFormat::Unknown); + } + + #[test] + fn test_beat_grid_calculations() { + let grid = BeatGrid { + track_id: TrackId(1), + first_beat_offset_ms: 500.0, // 0.5 seconds + beat_positions: vec![], + confidence: 0.95, + analyzed_at: Utc::now(), + }; + let bpm = 120.0; + + // At 120 BPM, beat interval is 0.5 seconds + assert!((BeatGrid::beat_interval_seconds(bpm) - 0.5).abs() < 0.001); + + // At position 1.0 seconds (0.5s after first beat), should be beat 1.0 + assert!((grid.beat_at_position(1.0, bpm) - 1.0).abs() < 0.001); + + // At position 0.75 seconds (0.25s into first beat), phase should be 0.5 + assert!((grid.beat_phase_at_position(0.75, bpm) - 0.5).abs() < 0.001); + } + + #[test] + fn test_nearest_beat() { + let grid = BeatGrid { + track_id: TrackId(1), + first_beat_offset_ms: 0.0, + beat_positions: vec![], + confidence: 0.95, + analyzed_at: Utc::now(), + }; + let bpm = 120.0; + + // At 120 BPM, beats are at 0.0, 0.5, 1.0, 1.5, etc. + // Position 0.2 should snap to 0.0 + assert!((grid.nearest_beat(0.2, bpm) - 0.0).abs() < 0.001); + // Position 0.3 should snap to 0.5 + assert!((grid.nearest_beat(0.3, bpm) - 0.5).abs() < 0.001); + // Position 0.75 should snap to 1.0 + assert!((grid.nearest_beat(0.75, bpm) - 1.0).abs() < 0.001); + // Position 1.24 should snap to 1.0 + assert!((grid.nearest_beat(1.24, bpm) - 1.0).abs() < 0.001); + // Position 1.26 should snap to 1.5 + assert!((grid.nearest_beat(1.26, bpm) - 1.5).abs() < 0.001); + } + + #[test] + fn test_beat_position_after() { + let bpm = 120.0; + + // At 120 BPM, beat interval is 0.5 seconds + // 4 beats after 0.0 should be 2.0 seconds + assert!((BeatGrid::beat_position_after(0.0, 4.0, bpm) - 2.0).abs() < 0.001); + // 8 beats after 0.0 should be 4.0 seconds + assert!((BeatGrid::beat_position_after(0.0, 8.0, bpm) - 4.0).abs() < 0.001); + // 4 beats after 1.0 should be 3.0 seconds + assert!((BeatGrid::beat_position_after(1.0, 4.0, bpm) - 3.0).abs() < 0.001); + } + + #[test] + fn test_tempo_range() { + let range = TempoRange::Range10; + + // At pitch 0.0, multiplier should be 1.0 + assert!((range.pitch_to_multiplier(0.0) - 1.0).abs() < 0.001); + + // At pitch 1.0, multiplier should be 1.10 + assert!((range.pitch_to_multiplier(1.0) - 1.10).abs() < 0.001); + + // At pitch -1.0, multiplier should be 0.90 + assert!((range.pitch_to_multiplier(-1.0) - 0.90).abs() < 0.001); + } + + #[test] + fn test_tempo_range_wide() { + let range = TempoRange::Wide; + + // Wide is ±100% + assert!((range.as_fraction() - 1.0).abs() < 0.001); + + // At pitch 0.0, multiplier should be 1.0 + assert!((range.pitch_to_multiplier(0.0) - 1.0).abs() < 0.001); + + // At pitch 1.0, multiplier should be 2.0 (double speed) + assert!((range.pitch_to_multiplier(1.0) - 2.0).abs() < 0.001); + + // At pitch -1.0, multiplier should be 0.0 (stopped) + assert!((range.pitch_to_multiplier(-1.0) - 0.0).abs() < 0.001); + } +} diff --git a/crates/dj/src/midi/mod.rs b/crates/dj/src/midi/mod.rs new file mode 100644 index 0000000..0a571cc --- /dev/null +++ b/crates/dj/src/midi/mod.rs @@ -0,0 +1,5 @@ +//! MIDI controller support for the DJ module. + +pub mod z1_mapping; + +pub use z1_mapping::Z1Mapping; diff --git a/crates/dj/src/midi/z1_mapping.rs b/crates/dj/src/midi/z1_mapping.rs new file mode 100644 index 0000000..628cd9d --- /dev/null +++ b/crates/dj/src/midi/z1_mapping.rs @@ -0,0 +1,161 @@ +//! TRAKTOR Kontrol Z1 MK1 MIDI mapping. +//! +//! The Z1 is a 2-channel mixer controller with: +//! - 2x Gain knobs +//! - 2x 3-band EQ (Hi/Mid/Lo) +//! - 2x Filter knobs +//! - 2x Volume faders +//! - 2x Cue buttons +//! - 2x FX buttons +//! - 1x Mode button +//! - 1x Crossfader +//! +//! In external mixer mode, the hardware EQ/filter/volume controls +//! are used directly on the mixer, so we can repurpose them in software. + +use crate::deck::DeckId; +use crate::module::DjCommand; + +/// TRAKTOR Kontrol Z1 MK1 MIDI CC and Note mappings. +/// +/// Note: These values are based on the default Z1 MIDI mapping. +/// Actual values may vary and should be verified via MIDI learn. +pub struct Z1Mapping; + +impl Z1Mapping { + // Control Change (CC) numbers for knobs and faders + pub const CC_GAIN_A: u8 = 16; + pub const CC_GAIN_B: u8 = 17; + pub const CC_EQ_HI_A: u8 = 18; + pub const CC_EQ_MID_A: u8 = 19; + pub const CC_EQ_LO_A: u8 = 20; + pub const CC_EQ_HI_B: u8 = 21; + pub const CC_EQ_MID_B: u8 = 22; + pub const CC_EQ_LO_B: u8 = 23; + pub const CC_FILTER_A: u8 = 24; + pub const CC_FILTER_B: u8 = 25; + pub const CC_VOLUME_A: u8 = 26; + pub const CC_VOLUME_B: u8 = 27; + pub const CC_CROSSFADER: u8 = 28; + + // Note numbers for buttons + pub const NOTE_CUE_A: u8 = 1; + pub const NOTE_CUE_B: u8 = 2; + pub const NOTE_FX_A: u8 = 3; + pub const NOTE_FX_B: u8 = 4; + pub const NOTE_MODE: u8 = 5; + + /// Translate a MIDI note on message to a DJ command. + pub fn translate_note_on(note: u8, _velocity: u8) -> Option { + match note { + Self::NOTE_CUE_A => Some(DjCommand::CuePreview { + deck: DeckId::A, + pressed: true, + }), + Self::NOTE_CUE_B => Some(DjCommand::CuePreview { + deck: DeckId::B, + pressed: true, + }), + Self::NOTE_FX_A => Some(DjCommand::PlayPause { deck: DeckId::A }), + Self::NOTE_FX_B => Some(DjCommand::PlayPause { deck: DeckId::B }), + Self::NOTE_MODE => Some(DjCommand::ToggleSync { deck: DeckId::A }), + _ => None, + } + } + + /// Translate a MIDI note off message to a DJ command. + pub fn translate_note_off(note: u8) -> Option { + match note { + Self::NOTE_CUE_A => Some(DjCommand::CuePreview { + deck: DeckId::A, + pressed: false, + }), + Self::NOTE_CUE_B => Some(DjCommand::CuePreview { + deck: DeckId::B, + pressed: false, + }), + _ => None, + } + } + + /// Translate a MIDI control change message to a DJ command. + /// + /// In external mixer mode, most CC messages go to hardware. + /// We can optionally use some knobs for software control. + pub fn translate_cc(cc: u8, value: u8) -> Option { + // Convert 0-127 MIDI value to 0.0-1.0 range + let normalized = value as f64 / 127.0; + + match cc { + // Filter knobs could be used for pitch/tempo in software + Self::CC_FILTER_A => { + // Map filter knob to pitch (-1.0 to 1.0) + let pitch = (normalized * 2.0) - 1.0; + Some(DjCommand::SetPitch { + deck: DeckId::A, + percent: pitch, + }) + } + Self::CC_FILTER_B => { + let pitch = (normalized * 2.0) - 1.0; + Some(DjCommand::SetPitch { + deck: DeckId::B, + percent: pitch, + }) + } + _ => None, + } + } + + /// Get the Z1 device name for MIDI port matching. + pub fn device_name() -> &'static str { + "Traktor Kontrol Z1" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_translate_note_on() { + let cmd = Z1Mapping::translate_note_on(Z1Mapping::NOTE_CUE_A, 127); + assert!(matches!( + cmd, + Some(DjCommand::CuePreview { + deck: DeckId::A, + pressed: true + }) + )); + + let cmd = Z1Mapping::translate_note_on(Z1Mapping::NOTE_FX_A, 127); + assert!(matches!( + cmd, + Some(DjCommand::PlayPause { deck: DeckId::A }) + )); + } + + #[test] + fn test_translate_note_off() { + let cmd = Z1Mapping::translate_note_off(Z1Mapping::NOTE_CUE_A); + assert!(matches!( + cmd, + Some(DjCommand::CuePreview { + deck: DeckId::A, + pressed: false + }) + )); + } + + #[test] + fn test_translate_cc() { + // Center position (64) should be pitch 0 + let cmd = Z1Mapping::translate_cc(Z1Mapping::CC_FILTER_A, 64); + if let Some(DjCommand::SetPitch { deck, percent }) = cmd { + assert_eq!(deck, DeckId::A); + assert!((percent - 0.0).abs() < 0.02); + } else { + panic!("Expected SetPitch command"); + } + } +} diff --git a/crates/dj/src/module/audio_engine.rs b/crates/dj/src/module/audio_engine.rs new file mode 100644 index 0000000..bf97ee5 --- /dev/null +++ b/crates/dj/src/module/audio_engine.rs @@ -0,0 +1,565 @@ +//! Multi-channel audio engine for DJ deck output. +//! +//! Handles routing two stereo deck outputs to separate output channels +//! on a multi-channel audio interface (e.g., Motu M4). + +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Device, SampleFormat, Stream, StreamConfig}; +use parking_lot::RwLock; + +use super::DeckPlayer; +use crate::deck::DeckId; + +/// Audio engine configuration. +#[derive(Debug, Clone)] +pub struct AudioEngineConfig { + /// Audio device name (empty for default). + pub device_name: String, + /// Sample rate in Hz. + pub sample_rate: u32, + /// Buffer size in samples. + pub buffer_size: u32, + /// Output channels for Deck A (left, right). + pub deck_a_channels: (u16, u16), + /// Output channels for Deck B (left, right). + pub deck_b_channels: (u16, u16), +} + +impl Default for AudioEngineConfig { + fn default() -> Self { + Self { + device_name: String::new(), + sample_rate: 44100, + buffer_size: 512, + // Deck A on outputs 1-2 (channels 0-1) + deck_a_channels: (0, 1), + // Deck B on outputs 3-4 (channels 2-3) + deck_b_channels: (2, 3), + } + } +} + +/// Multi-channel audio engine for DJ playback. +pub struct DjAudioEngine { + /// Configuration. + config: AudioEngineConfig, + /// Audio output stream. + stream: Option, + /// Deck A player. + deck_a_player: Arc>, + /// Deck B player. + deck_b_player: Arc>, + /// Number of output channels on the device. + output_channels: u16, + /// Which deck is the tempo master (None = auto-select playing deck). + master_deck: Option, +} + +impl DjAudioEngine { + /// Create a new audio engine with the given configuration. + pub fn new(config: AudioEngineConfig) -> Self { + Self { + config, + stream: None, + deck_a_player: Arc::new(RwLock::new(DeckPlayer::new(DeckId::A))), + deck_b_player: Arc::new(RwLock::new(DeckPlayer::new(DeckId::B))), + output_channels: 4, // Default to 4 channels + master_deck: None, + } + } + + /// Get a reference to a deck player. + pub fn deck_player(&self, id: DeckId) -> &Arc> { + match id { + DeckId::A => &self.deck_a_player, + DeckId::B => &self.deck_b_player, + } + } + + /// Find the audio device by name. + fn find_device(&self) -> Result { + let host = cpal::default_host(); + + if self.config.device_name.is_empty() { + return host + .default_output_device() + .ok_or_else(|| anyhow::anyhow!("No default output device available")); + } + + // Search for device by name + for device in host.output_devices()? { + if let Ok(name) = device.name() { + if name.contains(&self.config.device_name) { + log::info!("Found audio device: {}", name); + return Ok(device); + } + } + } + + // Fall back to default + log::warn!( + "Device '{}' not found, using default", + self.config.device_name + ); + host.default_output_device() + .ok_or_else(|| anyhow::anyhow!("No default output device available")) + } + + /// Find a supported stream config with the required number of channels. + fn find_config(&self, device: &Device) -> Result { + let supported_configs = device.supported_output_configs()?; + + // Find the maximum channel count needed + let max_channel = self + .config + .deck_a_channels + .0 + .max(self.config.deck_a_channels.1) + .max(self.config.deck_b_channels.0) + .max(self.config.deck_b_channels.1) + + 1; + + // Look for a config with enough channels + for config_range in supported_configs { + if config_range.channels() >= max_channel + && config_range.sample_format() == SampleFormat::F32 + { + // Check if our target sample rate is within the supported range + let target_rate = self.config.sample_rate; + + if target_rate >= config_range.min_sample_rate() + && target_rate <= config_range.max_sample_rate() + { + return Ok(config_range.with_sample_rate(target_rate).into()); + } + } + } + + // Fall back to default config + let default_config = device.default_output_config()?; + log::warn!( + "Could not find {}-channel config, using default ({} channels)", + max_channel, + default_config.channels() + ); + Ok(default_config.into()) + } + + /// Start the audio engine. + pub fn start(&mut self) -> Result<(), anyhow::Error> { + let device = self.find_device()?; + let config = self.find_config(&device)?; + + self.output_channels = config.channels; + log::info!( + "Starting audio engine: {} channels @ {} Hz", + config.channels, + config.sample_rate + ); + + let deck_a = Arc::clone(&self.deck_a_player); + let deck_b = Arc::clone(&self.deck_b_player); + let deck_a_channels = self.config.deck_a_channels; + let deck_b_channels = self.config.deck_b_channels; + let channels = config.channels as usize; + + let stream = device.build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Fill buffer with silence first + data.fill(0.0); + + // Process each frame + for frame in data.chunks_mut(channels) { + // Get samples from deck players + let (a_left, a_right) = deck_a.write().next_stereo_sample(); + let (b_left, b_right) = deck_b.write().next_stereo_sample(); + + // Route Deck A to configured channels + if (deck_a_channels.0 as usize) < frame.len() { + frame[deck_a_channels.0 as usize] = a_left; + } + if (deck_a_channels.1 as usize) < frame.len() { + frame[deck_a_channels.1 as usize] = a_right; + } + + // Route Deck B to configured channels + if (deck_b_channels.0 as usize) < frame.len() { + frame[deck_b_channels.0 as usize] = b_left; + } + if (deck_b_channels.1 as usize) < frame.len() { + frame[deck_b_channels.1 as usize] = b_right; + } + } + }, + |err| { + log::error!("Audio stream error: {}", err); + }, + None, + )?; + + stream.play()?; + self.stream = Some(stream); + + log::info!("Audio engine started"); + Ok(()) + } + + /// Stop the audio engine. + pub fn stop(&mut self) { + if let Some(stream) = self.stream.take() { + drop(stream); + log::info!("Audio engine stopped"); + } + } + + /// Check if the engine is running. + pub fn is_running(&self) -> bool { + self.stream.is_some() + } + + /// Get the number of output channels. + pub fn output_channels(&self) -> u16 { + self.output_channels + } + + // Master deck methods + + /// Set the master deck. + /// + /// The master deck provides the tempo reference for sync and lighting. + /// Pass `None` to auto-select the playing deck. + pub fn set_master_deck(&mut self, deck: Option) { + self.master_deck = deck; + log::debug!("Master deck set to: {:?}", deck); + } + + /// Get the current master deck. + /// + /// Returns the explicitly set master deck, or auto-selects based on + /// which deck is currently playing. + pub fn master_deck(&self) -> Option { + if let Some(deck) = self.master_deck { + return Some(deck); + } + + // Auto-select: prefer the deck that is playing + let a_playing = self.deck_a_player.read().state() == super::PlayerState::Playing; + let b_playing = self.deck_b_player.read().state() == super::PlayerState::Playing; + + match (a_playing, b_playing) { + (true, false) => Some(DeckId::A), + (false, true) => Some(DeckId::B), + (true, true) => Some(DeckId::A), // Both playing: prefer A + (false, false) => None, // Neither playing + } + } + + /// Get the master BPM (effective BPM of the master deck). + pub fn master_bpm(&self) -> Option { + let master = self.master_deck()?; + let player = self.deck_player(master).read(); + player.effective_bpm() + } + + /// Get the master beat phase (0.0-1.0). + pub fn master_beat_phase(&self) -> Option { + let master = self.master_deck()?; + let player = self.deck_player(master).read(); + player.beat_phase() + } + + /// Get the master bar phase (0.0-1.0). + pub fn master_bar_phase(&self) -> Option { + let master = self.master_deck()?; + let player = self.deck_player(master).read(); + player.bar_phase() + } + + /// Get the master phrase phase (0.0-1.0). + pub fn master_phrase_phase(&self) -> Option { + let master = self.master_deck()?; + let player = self.deck_player(master).read(); + player.phrase_phase() + } + + /// Update sync corrections for all decks with sync enabled. + /// + /// This should be called periodically (e.g., 20-50 times per second) to maintain + /// continuous beat lock between synced decks and the master. + pub fn update_sync_corrections(&self) { + let master = match self.master_deck() { + Some(m) => m, + None => return, // No master, nothing to sync + }; + + // Get master's bar phase + let master_phase = { + let player = self.deck_player(master).read(); + if player.state() != super::PlayerState::Playing { + return; // Master not playing, skip sync + } + match player.bar_phase() { + Some(p) => p, + None => return, + } + }; + + // Update sync correction for each non-master deck + for deck in [DeckId::A, DeckId::B] { + if deck == master { + continue; + } + + let mut player = self.deck_player(deck).write(); + + // Only sync if deck has sync enabled and is playing + if !player.is_sync_enabled() || player.state() != super::PlayerState::Playing { + continue; + } + + // Get this deck's bar phase + let sync_phase = match player.bar_phase() { + Some(p) => p, + None => continue, + }; + + // Calculate phase difference (-0.5 to 0.5, wrapped) + let mut phase_diff = master_phase - sync_phase; + if phase_diff > 0.5 { + phase_diff -= 1.0; + } else if phase_diff < -0.5 { + phase_diff += 1.0; + } + + // Calculate correction factor (proportional control) + // phase_diff of 0.25 (quarter bar behind) should speed up by ~1% + // This creates a soft lock that gradually catches up + let correction = phase_diff * 0.04; // 4% max correction for full quarter-bar offset + + player.set_sync_correction(correction); + } + } + + /// Sync a deck to the master deck's tempo and align beat phase. + /// + /// Returns true if sync was successful. + pub fn sync_to_master(&self, deck: DeckId, tempo_range: crate::library::TempoRange) -> bool { + // Get master deck + let master = match self.master_deck() { + Some(m) if m != deck => m, + _ => return false, // Can't sync to self or no master + }; + + // Get master BPM and bar phase + let (target_bpm, master_bar_phase) = { + let player = self.deck_player(master).read(); + match player.effective_bpm() { + Some(bpm) => (bpm, player.bar_phase()), + None => return false, + } + }; + + // Sync BPM first + let mut player = self.deck_player(deck).write(); + if !player.sync_to_bpm(target_bpm, tempo_range) { + return false; + } + + // Enable continuous sync on this deck + player.set_sync_enabled(true); + + // Now align beat phase if both decks have beat grids + if let (Some(master_phase), Some(sync_phase)) = (master_bar_phase, player.bar_phase()) { + // Calculate how far off we are (in bars, -0.5 to 0.5 wrapped) + let mut phase_diff = master_phase - sync_phase; + if phase_diff > 0.5 { + phase_diff -= 1.0; + } else if phase_diff < -0.5 { + phase_diff += 1.0; + } + + // Calculate the duration of one bar at current BPM + let effective_bpm = player.effective_bpm().unwrap_or(target_bpm); + let beat_duration = 60.0 / effective_bpm; // seconds per beat + let bar_duration = beat_duration * 4.0; // seconds per bar + + // Calculate time offset needed to align + let time_offset = phase_diff * bar_duration; + + // Only adjust if the offset is significant (> 10ms) + if time_offset.abs() > 0.01 { + let current_pos = player.position_seconds(); + let new_pos = (current_pos + time_offset).max(0.0); + player.seek(new_pos); + log::info!( + "Beat aligned: phase diff {:.3}, time offset {:.3}s, seeking to {:.3}s", + phase_diff, + time_offset, + new_pos + ); + } + } + + true + } + + /// Start playback on a synced deck, quantized to master's next beat. + /// + /// This implements Rekordbox-style quantized play start where playback is + /// delayed until the master's next beat boundary so beats align from the start. + /// + /// Returns true if playback was started or scheduled. + pub fn start_quantized_playback(&self, deck: DeckId) -> bool { + let master = match self.master_deck() { + Some(m) if m != deck => m, + _ => { + // No master or self is master - just play normally + self.deck_player(deck).write().play(); + return true; + } + }; + + // Get master's beat info + let (master_bpm, master_beat_phase) = { + let player = self.deck_player(master).read(); + match (player.effective_bpm(), player.beat_phase()) { + (Some(bpm), Some(phase)) => (bpm, phase), + _ => { + // Master has no beat grid - play normally + self.deck_player(deck).write().play(); + return true; + } + } + }; + + // Get syncing deck's first beat position and current position + let (sync_first_beat, sync_current_pos) = { + let player = self.deck_player(deck).read(); + let first_beat = player.first_beat_seconds().unwrap_or(0.0); + let current_pos = player.position_seconds(); + (first_beat, current_pos) + }; + + // Calculate time until master's next beat + let beat_duration = 60.0 / master_bpm; + let time_to_master_next_beat = beat_duration * (1.0 - master_beat_phase); + + // Calculate time from sync deck's current position to its first beat + let time_to_sync_first_beat = sync_first_beat - sync_current_pos; + + // Calculate the delay needed so first beats align + // We want: sync's first beat fires when master's next beat fires + let delay = time_to_master_next_beat - time_to_sync_first_beat; + + log::info!( + "Quantized play: master_phase={:.3}, time_to_master_beat={:.3}s, sync_first_beat={:.3}s, delay={:.3}s", + master_beat_phase, + time_to_master_next_beat, + sync_first_beat, + delay + ); + + let mut player = self.deck_player(deck).write(); + + if delay <= 0.01 { + // Delay is negligible or negative - need to seek forward in syncing deck + // Seek to position where first beat will align with master's current beat + let seek_pos = sync_current_pos - delay; + if seek_pos >= 0.0 && seek_pos < player.duration_seconds() { + player.seek(seek_pos); + } + player.play(); + log::info!("Quantized play: immediate start (seek to {:.3}s)", seek_pos); + } else { + // Schedule delayed playback + player.schedule_play_after(delay, sync_first_beat); + log::info!("Quantized play: delayed start in {:.3}s", delay); + } + + true + } + + /// Disable sync on a deck. + pub fn disable_sync(&self, deck: DeckId) { + let mut player = self.deck_player(deck).write(); + player.set_sync_enabled(false); + } +} + +impl Drop for DjAudioEngine { + fn drop(&mut self) { + self.stop(); + } +} + +/// Information about an audio output device. +#[derive(Debug, Clone)] +pub struct AudioDeviceInfo { + /// Device name. + pub name: String, + /// Maximum number of output channels. + pub max_channels: u16, + /// Whether this is the default device. + pub is_default: bool, +} + +/// List available audio output devices with their capabilities. +pub fn list_audio_devices() -> Vec { + let host = cpal::default_host(); + let default_name = host + .default_output_device() + .and_then(|d| d.name().ok()) + .unwrap_or_default(); + + let mut devices = Vec::new(); + + if let Ok(output_devices) = host.output_devices() { + for device in output_devices { + if let Ok(name) = device.name() { + // Find maximum channel count from supported configs + let max_channels = device + .supported_output_configs() + .ok() + .map(|configs| configs.map(|c| c.channels()).max().unwrap_or(2)) + .unwrap_or(2); + + devices.push(AudioDeviceInfo { + is_default: name == default_name, + name, + max_channels, + }); + } + } + } + + devices +} + +/// Get the default audio device name. +pub fn default_device_name() -> Option { + let host = cpal::default_host(); + host.default_output_device().and_then(|d| d.name().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = AudioEngineConfig::default(); + assert_eq!(config.deck_a_channels, (0, 1)); + assert_eq!(config.deck_b_channels, (2, 3)); + assert_eq!(config.sample_rate, 44100); + } + + #[test] + fn test_list_devices() { + // This should not panic even if no devices are available + let devices = list_audio_devices(); + println!("Available audio devices: {:?}", devices); + } +} diff --git a/crates/dj/src/module/deck_player.rs b/crates/dj/src/module/deck_player.rs new file mode 100644 index 0000000..2a68ae3 --- /dev/null +++ b/crates/dj/src/module/deck_player.rs @@ -0,0 +1,1463 @@ +//! Deck audio player for sample-by-sample playback. +//! +//! Handles decoding and playback of audio files with tempo/pitch adjustment. +//! Uses varispeed for tempo changes (tempo change = pitch change). +//! Includes beat tracking for lighting synchronization. + +use std::fs::File; +use std::path::Path; + +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::core::units::Time; + +use super::time_stretcher::TimeStretcher; +use crate::deck::DeckId; +use crate::library::{BeatGrid, MasterTempoMode, TempoRange}; + +/// State of the deck player. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerState { + /// No file loaded. + Empty, + /// File loaded, ready to play. + Ready, + /// Currently playing. + Playing, + /// Paused. + Paused, +} + +/// Information about a beat event. +#[derive(Debug, Clone, Copy)] +pub struct BeatEvent { + /// The beat number (0-indexed from start of track). + pub beat_number: u64, + /// Position in seconds where the beat occurred. + pub position_seconds: f64, + /// Whether this is a downbeat (first beat of a bar, every 4 beats). + pub is_downbeat: bool, + /// Whether this is the first beat of a phrase (every 16 beats). + pub is_phrase_start: bool, + /// BPM at this beat. + pub bpm: f64, +} + +/// Audio deck player for sample-accurate playback. +pub struct DeckPlayer { + /// Deck identifier. + deck_id: DeckId, + /// Current player state. + state: PlayerState, + /// Format reader. + format: Option>, + /// Audio decoder. + decoder: Option>, + /// Track ID for the audio stream. + track_id: Option, + /// Sample rate of the loaded file. + sample_rate: u32, + /// Number of channels in the loaded file. + channels: usize, + /// Current sample position (in source samples). + sample_position: u64, + /// Total samples in the file. + total_samples: u64, + /// Playback rate multiplier (1.0 = normal, affects pitch). + playback_rate: f64, + /// Current audio buffer (interleaved samples). + buffer: Vec, + /// Position in the current buffer (in samples, not frames). + buffer_position: usize, + /// Fractional position for varispeed interpolation. + fractional_position: f64, + /// Previous stereo sample for interpolation. + prev_sample: (f32, f32), + /// Current stereo sample for interpolation. + curr_sample: (f32, f32), + /// Cue point position in seconds (None if not set). + cue_point: Option, + /// Whether we need to seek on next decode. + pending_seek: Option, + /// Path to the loaded file (for reloading on seek). + loaded_path: Option, + + // Beat tracking fields + /// Beat grid for the loaded track. + beat_grid: Option, + /// Base BPM from track.bpm (single source of truth for tempo). + base_bpm: f64, + /// Current beat index in the beat grid. + current_beat_index: usize, + /// Beat event that occurred during the last sample (if any). + last_beat_event: Option, + /// Previous position for beat crossing detection. + prev_position_seconds: f64, + + // Hot cue fields + /// 4 hot cue positions in seconds (None if not set). + hot_cues: [Option; 4], + + // Loop fields + /// Loop IN point in samples (None if not set). + loop_in_sample: Option, + /// Loop OUT point in samples (None if not set). + loop_out_sample: Option, + /// Whether the loop is currently active. + loop_active: bool, + + // Master Tempo fields + /// Master Tempo mode (key lock). + master_tempo: MasterTempoMode, + /// Time stretcher for Master Tempo mode. + time_stretcher: TimeStretcher, + /// Current tempo range setting. + tempo_range: TempoRange, + + // Sync fields + /// Whether sync is enabled for this deck. + sync_enabled: bool, + /// Sync phase correction factor (-1.0 to 1.0, applied to playback rate). + /// Positive = speed up slightly, negative = slow down slightly. + sync_correction: f64, + /// Base playback rate (before sync correction is applied). + base_playback_rate: f64, + + // Quantized play fields + /// Scheduled play delay in seconds (for quantized sync start). + pending_play_delay: Option, + /// When the quantized play was scheduled. + play_scheduled_at: Option, + /// Virtual position offset for display during countdown (can be negative). + virtual_position_offset: f64, +} + +impl DeckPlayer { + /// Create a new deck player. + pub fn new(deck_id: DeckId) -> Self { + Self { + deck_id, + state: PlayerState::Empty, + format: None, + decoder: None, + track_id: None, + sample_rate: 44100, + channels: 2, + sample_position: 0, + total_samples: 0, + playback_rate: 1.0, + buffer: Vec::new(), + buffer_position: 0, + fractional_position: 0.0, + prev_sample: (0.0, 0.0), + curr_sample: (0.0, 0.0), + cue_point: None, + pending_seek: None, + loaded_path: None, + beat_grid: None, + base_bpm: 120.0, + current_beat_index: 0, + last_beat_event: None, + prev_position_seconds: 0.0, + hot_cues: [None; 4], + loop_in_sample: None, + loop_out_sample: None, + loop_active: false, + master_tempo: MasterTempoMode::Off, + time_stretcher: TimeStretcher::new(44100, 2), + tempo_range: TempoRange::default(), + sync_enabled: false, + sync_correction: 0.0, + base_playback_rate: 1.0, + pending_play_delay: None, + play_scheduled_at: None, + virtual_position_offset: 0.0, + } + } + + /// Load an audio file. + pub fn load>(&mut self, path: P) -> Result<(), anyhow::Error> { + let path = path.as_ref(); + log::info!("Deck {}: Loading file {:?}", self.deck_id, path); + + // Store the path for potential reloading + self.loaded_path = Some(path.to_path_buf()); + + // Open the file + let file = File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + // Create a hint for the format + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + // Probe the format + let probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + )?; + + let format = probed.format; + + // Find the first audio track + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow::anyhow!("No audio track found"))?; + + let track_id = track.id; + let codec_params = &track.codec_params; + + // Get audio parameters + self.sample_rate = codec_params.sample_rate.unwrap_or(44100); + self.channels = codec_params.channels.map(|c| c.count()).unwrap_or(2); + self.total_samples = codec_params.n_frames.unwrap_or(0); + + // Create the decoder + let decoder = + symphonia::default::get_codecs().make(codec_params, &DecoderOptions::default())?; + + self.format = Some(format); + self.decoder = Some(decoder); + self.track_id = Some(track_id); + self.sample_position = 0; + self.buffer.clear(); + self.buffer_position = 0; + self.fractional_position = 0.0; + self.prev_sample = (0.0, 0.0); + self.curr_sample = (0.0, 0.0); + self.cue_point = None; + self.pending_seek = None; + self.beat_grid = None; + self.current_beat_index = 0; + self.last_beat_event = None; + self.prev_position_seconds = 0.0; + self.hot_cues = [None; 4]; + self.loop_in_sample = None; + self.loop_out_sample = None; + self.loop_active = false; + // Reset time stretcher with new sample rate + self.time_stretcher = TimeStretcher::new(self.sample_rate, self.channels as u32); + // Reset playback rate to 1.0 (no pitch adjustment) + self.playback_rate = 1.0; + self.base_playback_rate = 1.0; + self.state = PlayerState::Ready; + + log::info!( + "Deck {}: Loaded {} Hz, {} channels, {} samples ({:.2}s)", + self.deck_id, + self.sample_rate, + self.channels, + self.total_samples, + self.duration_seconds() + ); + + Ok(()) + } + + /// Start playback. + pub fn play(&mut self) { + if matches!(self.state, PlayerState::Ready | PlayerState::Paused) { + self.state = PlayerState::Playing; + log::debug!("Deck {}: Playing", self.deck_id); + } + } + + /// Pause playback. + pub fn pause(&mut self) { + if self.state == PlayerState::Playing { + self.state = PlayerState::Paused; + log::debug!("Deck {}: Paused", self.deck_id); + } + } + + /// Stop playback and return to start. + pub fn stop(&mut self) { + if self.state != PlayerState::Empty { + self.state = PlayerState::Ready; + self.pending_seek = Some(0.0); + log::debug!("Deck {}: Stopped", self.deck_id); + } + } + + /// Eject the loaded file. + pub fn eject(&mut self) { + self.format = None; + self.decoder = None; + self.track_id = None; + self.sample_position = 0; + self.total_samples = 0; + self.buffer.clear(); + self.buffer_position = 0; + self.fractional_position = 0.0; + self.prev_sample = (0.0, 0.0); + self.curr_sample = (0.0, 0.0); + self.cue_point = None; + self.pending_seek = None; + self.loaded_path = None; + self.beat_grid = None; + self.current_beat_index = 0; + self.last_beat_event = None; + self.prev_position_seconds = 0.0; + self.hot_cues = [None; 4]; + self.loop_in_sample = None; + self.loop_out_sample = None; + self.loop_active = false; + self.time_stretcher.reset(); + self.state = PlayerState::Empty; + log::debug!("Deck {}: Ejected", self.deck_id); + } + + /// Set the playback rate (1.0 = normal speed). + pub fn set_playback_rate(&mut self, rate: f64) { + self.playback_rate = rate.clamp(0.5, 2.0); + // Update time stretcher tempo when in Master Tempo mode + if self.master_tempo == MasterTempoMode::On { + self.time_stretcher.set_tempo(self.playback_rate); + } + } + + /// Set the playback rate using a pitch fader value and tempo range. + /// + /// - `pitch`: Pitch fader position from -1.0 to 1.0 (0.0 = center) + /// - `tempo_range`: The tempo range setting (±6%, ±10%, etc.) + /// + /// Returns the resulting playback rate. + pub fn set_pitch(&mut self, pitch: f64, tempo_range: TempoRange) -> f64 { + let pitch = pitch.clamp(-1.0, 1.0); + let rate = tempo_range.pitch_to_multiplier(pitch); + self.base_playback_rate = rate; + self.playback_rate = self.effective_rate(); + self.tempo_range = tempo_range; + // Update time stretcher tempo when in Master Tempo mode + if self.master_tempo == MasterTempoMode::On { + self.time_stretcher.set_tempo(self.playback_rate); + } + rate + } + + /// Get the effective playback rate including sync correction. + fn effective_rate(&self) -> f64 { + if self.sync_enabled { + // Apply sync correction (typically very small, ±0.5%) + (self.base_playback_rate * (1.0 + self.sync_correction)).clamp(0.5, 2.0) + } else { + self.base_playback_rate + } + } + + /// Enable or disable sync mode. + pub fn set_sync_enabled(&mut self, enabled: bool) { + self.sync_enabled = enabled; + if !enabled { + // Reset correction when sync is disabled + self.sync_correction = 0.0; + self.playback_rate = self.base_playback_rate; + } + } + + /// Check if sync is enabled. + pub fn is_sync_enabled(&self) -> bool { + self.sync_enabled + } + + /// Set the sync phase correction. + /// + /// - `correction`: Small adjustment factor (e.g., 0.005 = speed up 0.5%) + pub fn set_sync_correction(&mut self, correction: f64) { + // Limit correction to ±2% to avoid audible pitch change + self.sync_correction = correction.clamp(-0.02, 0.02); + self.playback_rate = self.effective_rate(); + // Update time stretcher if in Master Tempo mode + if self.master_tempo == MasterTempoMode::On { + self.time_stretcher.set_tempo(self.playback_rate); + } + } + + /// Nudge the playback rate temporarily (for beatmatching). + /// + /// - `amount`: Nudge amount (-1.0 to 1.0, typically ±0.04 for 4% nudge) + /// + /// Call with 0.0 to return to the current pitch setting. + pub fn nudge(&mut self, amount: f64) { + // Nudge adds to the current rate (for live beatmatching) + // Typically used with small values like ±0.04 + let nudge = amount.clamp(-0.5, 0.5); + let base_rate = self.playback_rate; + // Apply nudge temporarily - caller should set back to base rate when released + self.playback_rate = (base_rate + nudge).clamp(0.5, 2.0); + } + + /// Calculate the pitch adjustment needed to match a target BPM. + /// + /// Returns the pitch fader value (-1.0 to 1.0) needed to match the target BPM + /// within the given tempo range. Returns None if the target BPM cannot be + /// reached within the tempo range. + /// + /// - `target_bpm`: The BPM to sync to + /// - `tempo_range`: The current tempo range setting + pub fn calculate_sync_pitch(&self, target_bpm: f64, tempo_range: TempoRange) -> Option { + let original_bpm = self.original_bpm()?; + if original_bpm <= 0.0 || target_bpm <= 0.0 { + return None; + } + + // Calculate required playback rate + let required_rate = target_bpm / original_bpm; + + // Convert rate to pitch fader value + // rate = 1.0 + (pitch * range_fraction) + // pitch = (rate - 1.0) / range_fraction + let range_fraction = tempo_range.as_fraction(); + let pitch = (required_rate - 1.0) / range_fraction; + + // Check if within range + if pitch >= -1.0 && pitch <= 1.0 { + Some(pitch) + } else { + None + } + } + + /// Sync this deck's playback rate to a target BPM. + /// + /// Returns true if sync was successful, false if target BPM is out of range. + /// + /// - `target_bpm`: The BPM to sync to + /// - `tempo_range`: The current tempo range setting + pub fn sync_to_bpm(&mut self, target_bpm: f64, tempo_range: TempoRange) -> bool { + if let Some(pitch) = self.calculate_sync_pitch(target_bpm, tempo_range) { + self.set_pitch(pitch, tempo_range); + log::debug!( + "Deck {}: Synced to {:.2} BPM (pitch: {:.3})", + self.deck_id, + target_bpm, + pitch + ); + true + } else { + log::warn!( + "Deck {}: Cannot sync to {:.2} BPM (out of range)", + self.deck_id, + target_bpm + ); + false + } + } + + /// Get the time in seconds of the first beat in this track. + pub fn first_beat_seconds(&self) -> Option { + self.beat_grid + .as_ref() + .and_then(|bg| bg.beat_positions.first().copied()) + } + + /// Schedule playback to start after a delay (for quantized sync start). + /// + /// - `delay_seconds`: How long to wait before starting playback + /// - `first_beat_time`: Time of first beat in track (for virtual position calculation) + pub fn schedule_play_after(&mut self, delay_seconds: f64, first_beat_time: f64) { + self.pending_play_delay = Some(delay_seconds); + self.play_scheduled_at = Some(std::time::Instant::now()); + // Virtual position starts negative (time before first beat fires) + self.virtual_position_offset = -(delay_seconds + first_beat_time); + self.state = PlayerState::Paused; // Show as "ready to play" + log::info!( + "Deck {}: Scheduled quantized play in {:.3}s (virtual pos: {:.3})", + self.deck_id, + delay_seconds, + self.virtual_position_offset + ); + } + + /// Check if waiting for quantized play start. + pub fn is_waiting_for_quantized_start(&self) -> bool { + self.pending_play_delay.is_some() + } + + /// Get the virtual position (including offset for quantized start countdown). + /// This can be negative when waiting for quantized play. + pub fn virtual_position(&self) -> f64 { + if let (Some(delay), Some(scheduled_at)) = (self.pending_play_delay, self.play_scheduled_at) + { + // During countdown, return negative position that counts up to 0 + let elapsed = scheduled_at.elapsed().as_secs_f64(); + let remaining = delay - elapsed; + if let Some(first_beat) = self.first_beat_seconds() { + // Position relative to first beat: negative means before first beat fires + -remaining - first_beat + self.position_seconds() + } else { + -remaining + self.position_seconds() + } + } else { + self.position_seconds() + self.virtual_position_offset + } + } + + /// Cancel any pending quantized play. + pub fn cancel_quantized_play(&mut self) { + self.pending_play_delay = None; + self.play_scheduled_at = None; + self.virtual_position_offset = 0.0; + } + + /// Check and trigger scheduled play if delay has elapsed. + /// Returns true if playback was just started. + pub fn check_quantized_play(&mut self) -> bool { + if let (Some(delay), Some(scheduled_at)) = (self.pending_play_delay, self.play_scheduled_at) + { + if scheduled_at.elapsed().as_secs_f64() >= delay { + // Time to start playback + self.pending_play_delay = None; + self.play_scheduled_at = None; + self.virtual_position_offset = 0.0; + self.state = PlayerState::Playing; + log::info!("Deck {}: Quantized play started", self.deck_id); + return true; + } + } + false + } + + /// Get the current effective BPM (adjusted for playback rate). + pub fn effective_bpm(&self) -> Option { + self.original_bpm().map(|bpm| bpm * self.playback_rate) + } + + /// Get the current position in seconds. + pub fn position_seconds(&self) -> f64 { + if self.sample_rate == 0 { + return 0.0; + } + self.sample_position as f64 / self.sample_rate as f64 + } + + /// Get the total duration in seconds. + pub fn duration_seconds(&self) -> f64 { + if self.sample_rate == 0 { + return 0.0; + } + self.total_samples as f64 / self.sample_rate as f64 + } + + /// Seek to a position in seconds. + pub fn seek(&mut self, position_seconds: f64) { + let position = position_seconds.clamp(0.0, self.duration_seconds()); + self.pending_seek = Some(position); + log::debug!("Deck {}: Seek requested to {:.2}s", self.deck_id, position); + } + + /// Perform the actual seek operation. + fn perform_seek(&mut self, position_seconds: f64) -> bool { + let Some(format) = &mut self.format else { + return false; + }; + + // Use symphonia's seek functionality + let seek_to = SeekTo::Time { + time: Time::from(position_seconds), + track_id: self.track_id, + }; + + match format.seek(SeekMode::Accurate, seek_to) { + Ok(seeked_to) => { + // Update our position based on what symphonia actually seeked to + self.sample_position = seeked_to.actual_ts; + self.buffer.clear(); + self.buffer_position = 0; + self.fractional_position = 0.0; + self.prev_sample = (0.0, 0.0); + self.curr_sample = (0.0, 0.0); + + // Reset the decoder after seeking + if let Some(decoder) = &mut self.decoder { + decoder.reset(); + } + + // Flush time stretcher buffer on seek + self.time_stretcher.reset(); + + log::debug!( + "Deck {}: Seeked to {:.2}s (sample {})", + self.deck_id, + position_seconds, + self.sample_position + ); + true + } + Err(e) => { + log::warn!("Deck {}: Seek failed: {}", self.deck_id, e); + false + } + } + } + + /// Set the cue point at the current position. + pub fn set_cue(&mut self) { + self.cue_point = Some(self.position_seconds()); + log::debug!( + "Deck {}: Cue point set at {:.2}s", + self.deck_id, + self.position_seconds() + ); + } + + /// Set the cue point at a specific position. + pub fn set_cue_at(&mut self, position_seconds: f64) { + self.cue_point = Some(position_seconds.clamp(0.0, self.duration_seconds())); + } + + /// Get the cue point position. + pub fn cue_point(&self) -> Option { + self.cue_point + } + + /// Jump to the cue point. + pub fn jump_to_cue(&mut self) { + if let Some(cue) = self.cue_point { + self.seek(cue); + } + } + + /// Get the playback rate. + pub fn playback_rate(&self) -> f64 { + self.playback_rate + } + + /// Get the Master Tempo mode. + pub fn master_tempo(&self) -> MasterTempoMode { + self.master_tempo + } + + /// Set the Master Tempo mode. + /// + /// - `Off`: Varispeed - pitch changes with tempo (lower latency) + /// - `On`: Time-stretch - pitch locked (key lock) + pub fn set_master_tempo(&mut self, mode: MasterTempoMode) { + if self.master_tempo != mode { + self.master_tempo = mode; + + match mode { + MasterTempoMode::On => { + // Reset and initialize time stretcher with current tempo + self.time_stretcher.reset(); + self.time_stretcher.set_tempo(self.playback_rate); + // Reset fractional position for clean start + self.fractional_position = 0.0; + + // Pre-fill the time stretcher to reduce initial latency + // SoundTouch needs ~100-200ms of audio to start producing output + let prefill_samples = (self.sample_rate as usize / 10).min(4410); // ~100ms + for _ in 0..prefill_samples { + if self.sample_position < self.total_samples { + let sample = self.read_next_raw_sample(); + self.sample_position += 1; + self.time_stretcher.push_sample(sample.0, sample.1); + } + } + + log::info!( + "Deck {}: Master Tempo enabled (tempo: {:.4}x, sample_rate: {} Hz, prefilled: {} samples)", + self.deck_id, + self.playback_rate, + self.sample_rate, + prefill_samples + ); + } + MasterTempoMode::Off => { + // Reset time stretcher when disabling + self.time_stretcher.reset(); + // Reset fractional position for varispeed + self.fractional_position = 0.0; + log::info!("Deck {}: Master Tempo disabled", self.deck_id); + } + } + } + } + + /// Toggle Master Tempo mode. + pub fn toggle_master_tempo(&mut self) { + let new_mode = match self.master_tempo { + MasterTempoMode::Off => MasterTempoMode::On, + MasterTempoMode::On => MasterTempoMode::Off, + }; + self.set_master_tempo(new_mode); + } + + /// Get the tempo range. + pub fn tempo_range(&self) -> TempoRange { + self.tempo_range + } + + /// Set the tempo range. + pub fn set_tempo_range(&mut self, range: TempoRange) { + self.tempo_range = range; + log::debug!("Deck {}: Tempo range set to {:?}", self.deck_id, range); + } + + /// Get the next stereo sample pair. + /// + /// Behavior depends on Master Tempo mode: + /// - **Off**: Varispeed interpolation (pitch changes with tempo) + /// - **On**: Time-stretching via SoundTouch (pitch locked, tempo changes independently) + /// + /// After calling this method, use `take_beat_event()` to check if a beat + /// crossing occurred during this sample period. + pub fn next_stereo_sample(&mut self) -> (f32, f32) { + // Clear any previous beat event + self.last_beat_event = None; + + if self.state != PlayerState::Playing { + return (0.0, 0.0); + } + + // Handle pending seek + if let Some(seek_pos) = self.pending_seek.take() { + self.perform_seek(seek_pos); + self.update_beat_index_for_position(); + } + + // Store previous position for beat crossing detection + self.prev_position_seconds = self.position_seconds(); + + // Route to appropriate playback method based on Master Tempo mode + let sample = match self.master_tempo { + MasterTempoMode::Off => self.next_varispeed_sample(), + MasterTempoMode::On => self.next_timestretched_sample(), + }; + + // Check for beat crossing + self.check_beat_crossing(); + + sample + } + + /// Get next sample using varispeed (pitch changes with tempo). + /// + /// Uses fractional positioning and linear interpolation: + /// - Rate 1.0: advance 1 sample per call (normal) + /// - Rate 2.0: advance 2 samples per call (double speed, octave up) + /// - Rate 0.5: advance 0.5 samples per call (half speed, octave down) + fn next_varispeed_sample(&mut self) -> (f32, f32) { + // Get current interpolated sample + let t = self.fractional_position.fract() as f32; + let left = self.prev_sample.0 * (1.0 - t) + self.curr_sample.0 * t; + let right = self.prev_sample.1 * (1.0 - t) + self.curr_sample.1 * t; + + // Advance position by playback rate + self.fractional_position += self.playback_rate; + + // Consume whole samples as needed + while self.fractional_position >= 1.0 { + self.fractional_position -= 1.0; + self.prev_sample = self.curr_sample; + self.curr_sample = self.read_next_raw_sample(); + self.sample_position += 1; + + // Check for loop wrap + if self.loop_active { + if let (Some(loop_out), Some(loop_in)) = (self.loop_out_sample, self.loop_in_sample) + { + if self.sample_position >= loop_out { + // Wrap back to loop IN point + let loop_in_seconds = loop_in as f64 / self.sample_rate as f64; + self.perform_seek(loop_in_seconds); + self.sample_position = loop_in; + log::trace!( + "Deck {}: Loop wrap at sample {} -> {}", + self.deck_id, + loop_out, + loop_in + ); + } + } + } + + // Check for end of file + if self.sample_position >= self.total_samples { + self.state = PlayerState::Ready; + self.pending_seek = Some(0.0); + return (0.0, 0.0); + } + } + + (left, right) + } + + /// Get next sample using time-stretching (pitch locked, tempo changes). + /// + /// Reads samples based on the tempo ratio and processes through SoundTouch + /// for WSOLA-based time stretching. This allows tempo changes without + /// affecting pitch (Master Tempo / key lock). + fn next_timestretched_sample(&mut self) -> (f32, f32) { + // Feed samples based on tempo ratio. + // At tempo 2.0, we need to feed ~2 input samples per output sample. + // At tempo 0.5, we need to feed ~0.5 input samples per output sample. + // We use fractional accumulation to handle this smoothly. + + // Accumulate input samples needed based on tempo + self.fractional_position += self.playback_rate; + + // Feed whole samples to time stretcher + while self.fractional_position >= 1.0 && self.sample_position < self.total_samples { + let sample = self.read_next_raw_sample(); + self.sample_position += 1; + self.time_stretcher.push_sample(sample.0, sample.1); + self.fractional_position -= 1.0; + + // Check for loop wrap + if self.loop_active { + if let (Some(loop_out), Some(loop_in)) = (self.loop_out_sample, self.loop_in_sample) + { + if self.sample_position >= loop_out { + // Wrap back to loop IN point + let loop_in_seconds = loop_in as f64 / self.sample_rate as f64; + self.perform_seek(loop_in_seconds); + self.sample_position = loop_in; + // Reset time stretcher for clean loop transition + self.time_stretcher.reset(); + log::trace!( + "Deck {}: Loop wrap (timestretched) at sample {} -> {}", + self.deck_id, + loop_out, + loop_in + ); + } + } + } + } + + // Check for end of file + if self.sample_position >= self.total_samples && !self.time_stretcher.has_output() { + self.state = PlayerState::Ready; + self.pending_seek = Some(0.0); + return (0.0, 0.0); + } + + // Get processed sample from time stretcher + // If no output available yet (latency), return silence + self.time_stretcher.pop_sample().unwrap_or((0.0, 0.0)) + } + + /// Read the next raw stereo sample from the decoder buffer. + fn read_next_raw_sample(&mut self) -> (f32, f32) { + // Decode more data if needed + if self.buffer_position >= self.buffer.len() { + if !self.decode_next_packet() { + return (0.0, 0.0); + } + } + + // Get the next sample pair from buffer + let left = self + .buffer + .get(self.buffer_position) + .copied() + .unwrap_or(0.0); + let right = if self.channels >= 2 { + self.buffer + .get(self.buffer_position + 1) + .copied() + .unwrap_or(left) + } else { + left + }; + + self.buffer_position += self.channels; + (left, right) + } + + /// Decode the next packet of audio data. + fn decode_next_packet(&mut self) -> bool { + let Some(track_id) = self.track_id else { + return false; + }; + + // Read the next packet + let packet = { + let Some(format) = &mut self.format else { + return false; + }; + match format.next_packet() { + Ok(packet) => packet, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + return false; + } + Err(e) => { + log::warn!("Deck {}: Error reading packet: {}", self.deck_id, e); + return false; + } + } + }; + + // Skip packets that don't belong to our track + if packet.track_id() != track_id { + return self.decode_next_packet(); + } + + // Decode the packet and copy samples to a temporary buffer + let new_samples = { + let Some(decoder) = &mut self.decoder else { + return false; + }; + + match decoder.decode(&packet) { + Ok(decoded) => { + let mut samples = Vec::new(); + + // Copy samples to temporary buffer + match &decoded { + AudioBufferRef::F32(buf) => { + for frame in 0..buf.frames() { + for ch in 0..buf.spec().channels.count() { + samples.push(buf.chan(ch)[frame]); + } + } + } + AudioBufferRef::S16(buf) => { + for frame in 0..buf.frames() { + for ch in 0..buf.spec().channels.count() { + samples.push(buf.chan(ch)[frame] as f32 / 32768.0); + } + } + } + AudioBufferRef::S32(buf) => { + for frame in 0..buf.frames() { + for ch in 0..buf.spec().channels.count() { + samples.push(buf.chan(ch)[frame] as f32 / 2147483648.0); + } + } + } + AudioBufferRef::U8(buf) => { + for frame in 0..buf.frames() { + for ch in 0..buf.spec().channels.count() { + samples.push((buf.chan(ch)[frame] as f32 - 128.0) / 128.0); + } + } + } + _ => { + log::warn!("Unsupported audio buffer format"); + } + } + + Some(samples) + } + Err(e) => { + log::warn!("Deck {}: Error decoding: {}", self.deck_id, e); + None + } + } + }; + + // Now we can safely modify self.buffer + if let Some(samples) = new_samples { + self.buffer = samples; + self.buffer_position = 0; + true + } else { + false + } + } + + /// Get the current player state. + pub fn state(&self) -> PlayerState { + self.state + } + + /// Get the sample rate. + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Get the number of channels. + pub fn channels(&self) -> usize { + self.channels + } + + // Beat tracking methods + + /// Set the beat grid for beat tracking. + /// + /// The `bpm` parameter should come from `track.bpm` (the single source of truth). + pub fn set_beat_grid(&mut self, beat_grid: BeatGrid, bpm: f64) { + log::debug!( + "Deck {}: Beat grid set - BPM: {:.2}, {} beats", + self.deck_id, + bpm, + beat_grid.beat_positions.len() + ); + self.beat_grid = Some(beat_grid); + self.base_bpm = bpm; + self.update_beat_index_for_position(); + } + + /// Clear the beat grid. + pub fn clear_beat_grid(&mut self) { + self.beat_grid = None; + self.current_beat_index = 0; + self.last_beat_event = None; + } + + /// Get the beat grid (if set). + pub fn beat_grid(&self) -> Option<&BeatGrid> { + self.beat_grid.as_ref() + } + + /// Get a mutable reference to the beat grid (if set). + pub fn beat_grid_mut(&mut self) -> Option<&mut BeatGrid> { + self.beat_grid.as_mut() + } + + /// Get the BPM (adjusted for playback rate). + pub fn bpm(&self) -> Option { + if self.beat_grid.is_some() { + Some(self.base_bpm * self.playback_rate) + } else { + None + } + } + + /// Get the original BPM (from track.bpm). + pub fn original_bpm(&self) -> Option { + if self.beat_grid.is_some() { + Some(self.base_bpm) + } else { + None + } + } + + /// Get the current beat number (0-indexed). + pub fn current_beat_number(&self) -> Option { + if self.beat_grid.is_some() { + Some(self.current_beat_index as u64) + } else { + None + } + } + + /// Get the current beat phase (0.0 to 1.0 within the current beat). + pub fn beat_phase(&self) -> Option { + let beat_grid = self.beat_grid.as_ref()?; + let positions = &beat_grid.beat_positions; + + if positions.is_empty() { + return None; + } + + let current_pos = self.position_seconds(); + + // If before first beat, calculate "virtual" beat phase + // This allows proper sync alignment when starting before the first beat + if current_pos < positions[0] { + let time_to_first = positions[0] - current_pos; + let beat_duration = 60.0 / self.base_bpm; + let beats_before = time_to_first / beat_duration; + // Phase counts backwards from 1.0 (e.g., 0.5 beats before = phase 0.5) + let phase = 1.0 - (beats_before - beats_before.floor()); + return Some(if phase >= 1.0 { 0.0 } else { phase }); + } + + // Find current beat interval + if self.current_beat_index < positions.len() { + let beat_start = positions[self.current_beat_index]; + let beat_end = if self.current_beat_index + 1 < positions.len() { + positions[self.current_beat_index + 1] + } else { + // Estimate next beat using BPM + beat_start + 60.0 / self.base_bpm + }; + + let beat_duration = beat_end - beat_start; + if beat_duration > 0.0 { + return Some(((current_pos - beat_start) / beat_duration).clamp(0.0, 1.0)); + } + } + + Some(0.0) + } + + /// Get the current bar phase (0.0 to 1.0 within the current 4-beat bar). + pub fn bar_phase(&self) -> Option { + let beat_num = self.current_beat_number()?; + let beat_phase = self.beat_phase()?; + let beat_in_bar = (beat_num % 4) as f64; + Some((beat_in_bar + beat_phase) / 4.0) + } + + /// Get the current phrase phase (0.0 to 1.0 within the current 16-beat phrase). + pub fn phrase_phase(&self) -> Option { + let beat_num = self.current_beat_number()?; + let beat_phase = self.beat_phase()?; + let beat_in_phrase = (beat_num % 16) as f64; + Some((beat_in_phrase + beat_phase) / 16.0) + } + + /// Take the last beat event (if any), consuming it. + /// + /// This should be called after each `next_stereo_sample()` to check + /// if a beat occurred during that sample period. + pub fn take_beat_event(&mut self) -> Option { + self.last_beat_event.take() + } + + /// Peek at the last beat event without consuming it. + pub fn peek_beat_event(&self) -> Option<&BeatEvent> { + self.last_beat_event.as_ref() + } + + /// Update beat index to match current position (used after seeking). + fn update_beat_index_for_position(&mut self) { + let Some(beat_grid) = &self.beat_grid else { + return; + }; + + let current_pos = self.position_seconds(); + let positions = &beat_grid.beat_positions; + + // Binary search for the appropriate beat index + self.current_beat_index = match positions.binary_search_by(|pos| { + pos.partial_cmp(¤t_pos) + .unwrap_or(std::cmp::Ordering::Equal) + }) { + Ok(idx) => idx, + Err(idx) => idx.saturating_sub(1), + }; + } + + /// Check if a beat crossing occurred between prev and current position. + fn check_beat_crossing(&mut self) { + let Some(beat_grid) = &self.beat_grid else { + return; + }; + + let positions = &beat_grid.beat_positions; + if positions.is_empty() { + return; + } + + let current_pos = self.position_seconds(); + let prev_pos = self.prev_position_seconds; + + // Check if we crossed any beat positions + while self.current_beat_index < positions.len() { + let beat_pos = positions[self.current_beat_index]; + + // Did we cross this beat? + if prev_pos < beat_pos && current_pos >= beat_pos { + let beat_number = self.current_beat_index as u64; + + self.last_beat_event = Some(BeatEvent { + beat_number, + position_seconds: beat_pos, + is_downbeat: beat_number % 4 == 0, + is_phrase_start: beat_number % 16 == 0, + bpm: self.base_bpm * self.playback_rate, + }); + + self.current_beat_index += 1; + return; // Only emit one beat per sample + } else if current_pos < beat_pos { + // Haven't reached this beat yet + break; + } else { + // Already past this beat + self.current_beat_index += 1; + } + } + } + + // Hot cue methods + + /// Set a hot cue at the given slot (0-3) to the current position. + pub fn set_hot_cue(&mut self, slot: u8) { + if slot < 4 { + let position = self.position_seconds(); + self.hot_cues[slot as usize] = Some(position); + log::debug!( + "Deck {}: Hot cue {} set at {:.2}s", + self.deck_id, + slot + 1, + position + ); + } + } + + /// Set a hot cue at the given slot to a specific position. + pub fn set_hot_cue_at(&mut self, slot: u8, position_seconds: f64) { + if slot < 4 { + let position = position_seconds.clamp(0.0, self.duration_seconds()); + self.hot_cues[slot as usize] = Some(position); + log::debug!( + "Deck {}: Hot cue {} set at {:.2}s", + self.deck_id, + slot + 1, + position + ); + } + } + + /// Clear a hot cue at the given slot. + pub fn clear_hot_cue(&mut self, slot: u8) { + if slot < 4 { + self.hot_cues[slot as usize] = None; + log::debug!("Deck {}: Hot cue {} cleared", self.deck_id, slot + 1); + } + } + + /// Jump to a hot cue and start playing. + pub fn trigger_hot_cue(&mut self, slot: u8) { + if slot < 4 { + if let Some(position) = self.hot_cues[slot as usize] { + self.seek(position); + self.play(); + log::debug!( + "Deck {}: Triggered hot cue {} at {:.2}s", + self.deck_id, + slot + 1, + position + ); + } else { + // If hot cue not set, set it at current position + self.set_hot_cue(slot); + } + } + } + + /// Get the position of a hot cue. + pub fn hot_cue(&self, slot: u8) -> Option { + if slot < 4 { + self.hot_cues[slot as usize] + } else { + None + } + } + + /// Get all hot cue positions. + pub fn hot_cues(&self) -> &[Option; 4] { + &self.hot_cues + } + + /// Load hot cue positions from HotCue structs (from database). + pub fn load_hot_cues(&mut self, hot_cues: &[crate::library::HotCue]) { + self.hot_cues = [None; 4]; + for cue in hot_cues { + if cue.slot < 4 { + self.hot_cues[cue.slot as usize] = Some(cue.position_seconds); + } + } + log::debug!("Deck {}: Loaded {} hot cues", self.deck_id, hot_cues.len()); + } + + // Loop methods + + /// Set the loop points in seconds. + /// + /// Converts the time positions to sample positions for accurate looping. + pub fn set_loop(&mut self, loop_in: f64, loop_out: f64) { + // Convert seconds to samples + self.loop_in_sample = Some((loop_in * self.sample_rate as f64) as u64); + self.loop_out_sample = Some((loop_out * self.sample_rate as f64) as u64); + self.loop_active = true; + log::debug!( + "Deck {}: Loop set from {:.2}s to {:.2}s (samples {} to {})", + self.deck_id, + loop_in, + loop_out, + self.loop_in_sample.unwrap(), + self.loop_out_sample.unwrap() + ); + } + + /// Enable or disable the loop. + pub fn set_loop_active(&mut self, active: bool) { + if self.loop_in_sample.is_some() && self.loop_out_sample.is_some() { + self.loop_active = active; + log::debug!("Deck {}: Loop active = {}", self.deck_id, active); + } + } + + /// Clear the loop points. + pub fn clear_loop(&mut self) { + self.loop_in_sample = None; + self.loop_out_sample = None; + self.loop_active = false; + log::debug!("Deck {}: Loop cleared", self.deck_id); + } + + /// Check if a loop is defined (has IN and OUT points). + pub fn is_loop_defined(&self) -> bool { + self.loop_in_sample.is_some() && self.loop_out_sample.is_some() + } + + /// Check if the loop is active. + pub fn is_loop_active(&self) -> bool { + self.loop_active + } + + /// Get loop IN point in seconds. + pub fn loop_in_seconds(&self) -> Option { + self.loop_in_sample + .map(|s| s as f64 / self.sample_rate as f64) + } + + /// Get loop OUT point in seconds. + pub fn loop_out_seconds(&self) -> Option { + self.loop_out_sample + .map(|s| s as f64 / self.sample_rate as f64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_player() { + let player = DeckPlayer::new(DeckId::A); + assert_eq!(player.state(), PlayerState::Empty); + assert_eq!(player.position_seconds(), 0.0); + } + + #[test] + fn test_empty_player_samples() { + let mut player = DeckPlayer::new(DeckId::A); + let (left, right) = player.next_stereo_sample(); + assert_eq!(left, 0.0); + assert_eq!(right, 0.0); + } + + #[test] + fn test_hot_cues() { + let mut player = DeckPlayer::new(DeckId::A); + + // All hot cues should be empty initially + assert!(player.hot_cue(0).is_none()); + assert!(player.hot_cue(1).is_none()); + assert!(player.hot_cue(2).is_none()); + assert!(player.hot_cue(3).is_none()); + + // Simulate having a loaded track by setting total_samples + player.total_samples = 44100 * 60; // 60 seconds at 44100Hz + + // Set hot cue at specific position + player.set_hot_cue_at(0, 10.5); + assert!((player.hot_cue(0).unwrap() - 10.5).abs() < 0.001); + + // Set another hot cue + player.set_hot_cue_at(2, 30.0); + assert!((player.hot_cue(2).unwrap() - 30.0).abs() < 0.001); + + // Clear hot cue + player.clear_hot_cue(0); + assert!(player.hot_cue(0).is_none()); + + // Invalid slot should be ignored + player.set_hot_cue_at(5, 100.0); + assert!(player.hot_cue(5).is_none()); + } + + #[test] + fn test_playback_rate() { + let mut player = DeckPlayer::new(DeckId::A); + + // Default rate should be 1.0 + assert!((player.playback_rate() - 1.0).abs() < 0.001); + + // Set to 1.1 (10% faster) + player.set_playback_rate(1.1); + assert!((player.playback_rate() - 1.1).abs() < 0.001); + + // Clamping should work + player.set_playback_rate(3.0); + assert!((player.playback_rate() - 2.0).abs() < 0.001); + + player.set_playback_rate(0.1); + assert!((player.playback_rate() - 0.5).abs() < 0.001); + } + + #[test] + fn test_pitch_control() { + use crate::library::TempoRange; + + let mut player = DeckPlayer::new(DeckId::A); + + // Center pitch should be 1.0 + let rate = player.set_pitch(0.0, TempoRange::Range10); + assert!((rate - 1.0).abs() < 0.001); + + // +10% at full pitch with Range10 + let rate = player.set_pitch(1.0, TempoRange::Range10); + assert!((rate - 1.1).abs() < 0.001); + + // -10% at full negative pitch with Range10 + let rate = player.set_pitch(-1.0, TempoRange::Range10); + assert!((rate - 0.9).abs() < 0.001); + + // +6% at full pitch with Range6 + let rate = player.set_pitch(1.0, TempoRange::Range6); + assert!((rate - 1.06).abs() < 0.001); + + // Half pitch position with Range10 should give +5% + let rate = player.set_pitch(0.5, TempoRange::Range10); + assert!((rate - 1.05).abs() < 0.001); + } + + #[test] + fn test_beat_sync() { + use chrono::Utc; + + use crate::library::{BeatGrid, TempoRange, TrackId}; + + let mut player = DeckPlayer::new(DeckId::A); + + // Set up a beat grid at 120 BPM + let bpm = 120.0; + let beat_grid = BeatGrid { + track_id: TrackId(1), + first_beat_offset_ms: 0.0, + beat_positions: vec![], + confidence: 0.95, + analyzed_at: Utc::now(), + }; + player.set_beat_grid(beat_grid, bpm); + + // Original BPM should be 120 + assert!((player.original_bpm().unwrap() - 120.0).abs() < 0.001); + + // Sync to 126 BPM (5% faster) + let result = player.sync_to_bpm(126.0, TempoRange::Range10); + assert!(result); + + // Effective BPM should now be 126 + assert!((player.effective_bpm().unwrap() - 126.0).abs() < 0.5); + + // Playback rate should be 1.05 + assert!((player.playback_rate() - 1.05).abs() < 0.001); + + // Sync to 130 BPM (should be within range) + let result = player.sync_to_bpm(130.0, TempoRange::Range10); + assert!(result); + + // Sync to 140 BPM with Range10 (out of range - would need 16.7% increase) + let result = player.sync_to_bpm(140.0, TempoRange::Range10); + assert!(!result); + + // But Wide should work (120 * 2.0 = 240, so 140 is within range) + let result = player.sync_to_bpm(140.0, TempoRange::Wide); + assert!(result); + + // Verify effective BPM is now 140 + assert!((player.effective_bpm().unwrap() - 140.0).abs() < 0.5); + } +} diff --git a/crates/dj/src/module/mod.rs b/crates/dj/src/module/mod.rs new file mode 100644 index 0000000..eb3b9b6 --- /dev/null +++ b/crates/dj/src/module/mod.rs @@ -0,0 +1,2907 @@ +//! DJ module implementation. + +mod audio_engine; +mod deck_player; +mod time_stretcher; + +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +pub use audio_engine::{ + default_device_name, list_audio_devices, AudioDeviceInfo, AudioEngineConfig, DjAudioEngine, +}; +pub use deck_player::{BeatEvent, DeckPlayer, PlayerState}; +use halo_core::{AsyncModule, MidiMessage, ModuleEvent, ModuleId, ModuleMessage}; +use parking_lot::RwLock; +use tokio::sync::mpsc; + +use crate::deck::{Deck, DeckId, DeckState}; +use crate::library::database::LibraryDatabase; +use crate::library::import::{import_file_metadata_only, scan_directory_for_audio}; +use crate::library::{ + analyze_file_streaming, AnalysisConfig, AnalysisResult, BeatGrid, HotCue, MasterTempoMode, + TempoRange, Track, TrackId, TrackWaveform, +}; +use crate::midi::z1_mapping::Z1Mapping; + +/// Minimum loop size in beats (1/32 beat). +const MIN_LOOP_BEATS: f64 = 0.03125; +/// Maximum loop size in beats (512 beats). +const MAX_LOOP_BEATS: f64 = 512.0; + +/// Commands for the DJ module. +#[derive(Debug, Clone)] +pub enum DjCommand { + // Library commands + /// Import all audio files from a folder. + ImportFolder { path: PathBuf }, + /// Analyze a track for BPM/beat grid. + AnalyzeTrack { track_id: TrackId }, + /// Search the library. + SearchLibrary { query: String }, + /// Get all tracks in the library. + GetAllTracks, + /// Re-analyze a track's BPM. + ReanalyzeTrack { track_id: TrackId }, + /// Update a track's BPM manually. + UpdateTrackBpm { track_id: TrackId, bpm: f64 }, + /// Delete a track from the library. + DeleteTrack { track_id: TrackId }, + + // Deck loading commands + /// Load a track onto a deck. + LoadTrack { deck: DeckId, track_id: TrackId }, + /// Eject the track from a deck. + EjectTrack { deck: DeckId }, + + // Track navigation commands + /// Go to previous track (or start of current track if not at start). + PreviousTrack { deck: DeckId }, + /// Go to next track in the library. + NextTrack { deck: DeckId }, + + // Playback commands + /// Start playback. + Play { deck: DeckId }, + /// Pause playback. + Pause { deck: DeckId }, + /// Toggle play/pause. + PlayPause { deck: DeckId }, + /// Stop playback (return to start). + Stop { deck: DeckId }, + + // Cueing commands + /// Set the cue point at current position. + SetCue { deck: DeckId }, + /// Jump to cue point and start playing. + CuePlay { deck: DeckId }, + /// Preview from cue point while button is held. + CuePreview { deck: DeckId, pressed: bool }, + + // Hot cue commands + /// Set a hot cue at current position. + SetHotCue { deck: DeckId, slot: u8 }, + /// Jump to a hot cue. + JumpToHotCue { deck: DeckId, slot: u8 }, + /// Clear a hot cue. + ClearHotCue { deck: DeckId, slot: u8 }, + + // Tempo commands + /// Set the pitch fader position (-1.0 to 1.0). + SetPitch { deck: DeckId, percent: f64 }, + /// Set the tempo range. + SetTempoRange { deck: DeckId, range: TempoRange }, + /// Nudge tempo temporarily. + NudgeTempo { + deck: DeckId, + direction: NudgeDirection, + }, + + // Sync commands + /// Set this deck as the tempo master. + SetMaster { deck: DeckId }, + /// Toggle sync mode for this deck. + ToggleSync { deck: DeckId }, + /// Sync this deck to the other deck. + SyncToDeck { deck: DeckId }, + + // Seek commands + /// Seek to a position in seconds. + Seek { deck: DeckId, position_seconds: f64 }, + /// Seek by a number of beats. + SeekBeats { deck: DeckId, beats: i32 }, + + // Master Tempo commands + /// Toggle Master Tempo (key lock) mode. + ToggleMasterTempo { deck: DeckId }, + + // Loop commands + /// Set a quantized loop (4 or 8 beats). + SetLoop { deck: DeckId, beat_count: u8 }, + /// Toggle loop on/off (reloop/exit). + ToggleLoop { deck: DeckId }, + /// Halve the current loop length. + HalveLoop { deck: DeckId }, + /// Double the current loop length. + DoubleLoop { deck: DeckId }, + + // Beat grid commands + /// Nudge the beat grid offset. + NudgeBeatGrid { deck: DeckId, offset_ms: f64 }, + /// Set the downbeat at the current playback position. + SetDownbeat { deck: DeckId }, + /// Shift the beat grid by whole beat intervals. + ShiftBeatGrid { deck: DeckId, beats: i32 }, + + // Configuration commands + /// Set the output channels for a deck. + SetOutputChannels { deck: DeckId, channels: (u16, u16) }, + /// Set the audio device. + SetAudioDevice { device_name: String }, +} + +/// Direction for tempo nudge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NudgeDirection { + Forward, + Backward, +} + +/// Events emitted by the DJ module. +#[derive(Debug, Clone)] +pub enum DjEvent { + // State updates + /// Deck state has changed. + DeckStateChanged { deck: DeckId, state: Deck }, + /// A track was loaded onto a deck. + TrackLoaded { deck: DeckId, track: Track }, + /// A track was ejected from a deck. + TrackEjected { deck: DeckId }, + + // Playback updates + /// Playback position updated. + PositionUpdated { + deck: DeckId, + position_seconds: f64, + position_beats: f64, + }, + /// A beat was triggered. + BeatTriggered { + deck: DeckId, + beat_number: u64, + is_downbeat: bool, + }, + + // Tempo updates + /// Tempo changed on a deck. + TempoChanged { deck: DeckId, bpm: f64 }, + /// Master deck changed. + MasterChanged { deck: Option }, + + // Library updates + /// Library was updated. + LibraryUpdated { track_count: usize }, + /// Track analysis progress. + AnalysisProgress { track_id: TrackId, progress: f32 }, + /// Track analysis completed. + AnalysisComplete { + track_id: TrackId, + beat_grid: BeatGrid, + }, + /// Search results ready. + SearchResults { tracks: Vec }, + /// All tracks retrieved. + AllTracks { tracks: Vec }, + + // Waveform data + /// Waveform data available for a track. + WaveformReady { + track_id: TrackId, + waveform: TrackWaveform, + }, + + // Hot cues + /// Hot cue was set. + HotCueSet { deck: DeckId, slot: u8, cue: HotCue }, + /// Hot cue was cleared. + HotCueCleared { deck: DeckId, slot: u8 }, + + // Errors + /// An error occurred. + Error { message: String }, +} + +/// Track pending analysis. +#[derive(Debug, Clone)] +struct PendingAnalysis { + track_id: TrackId, + file_path: PathBuf, + track_name: String, +} + +/// DJ module state and audio engine. +pub struct DjModule { + /// Deck A state. + deck_a: Arc>, + /// Deck B state. + deck_b: Arc>, + /// Current master deck. + master_deck: Option, + /// Library database path. + library_path: PathBuf, + /// Audio engine configuration. + audio_config: AudioEngineConfig, + /// Audio engine (created during initialization). + audio_engine: Option, + /// Library database (created during initialization, wrapped for thread safety). + database: Option>>, + /// Queue of tracks pending background analysis. + analysis_queue: VecDeque, + /// Total number of tracks in current analysis batch (for progress display). + analysis_batch_total: usize, + /// Number of tracks completed in current analysis batch. + analysis_batch_completed: usize, + /// Handle to the currently running background analysis task. + analysis_handle: Option>>, + /// Receiver for streaming waveform progress from background analysis. + analysis_progress_rx: Option, f32)>>, + /// Currently analyzing track info (track_id, track_name). + current_analysis_track: Option<(TrackId, String)>, +} + +impl DjModule { + /// Create a new DJ module. + pub fn new() -> Self { + // Default library path: ~/.halo/library.db + let library_path = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".halo") + .join("library.db"); + + Self { + deck_a: Arc::new(RwLock::new(Deck::new(DeckId::A))), + deck_b: Arc::new(RwLock::new(Deck::new(DeckId::B))), + master_deck: None, + library_path, + audio_config: AudioEngineConfig::default(), + audio_engine: None, + database: None, + analysis_queue: VecDeque::new(), + analysis_batch_total: 0, + analysis_batch_completed: 0, + analysis_handle: None, + analysis_progress_rx: None, + current_analysis_track: None, + } + } + + /// Create a new DJ module with a specific library path. + pub fn with_library_path(library_path: PathBuf) -> Self { + Self { + deck_a: Arc::new(RwLock::new(Deck::new(DeckId::A))), + deck_b: Arc::new(RwLock::new(Deck::new(DeckId::B))), + master_deck: None, + library_path, + audio_config: AudioEngineConfig::default(), + audio_engine: None, + database: None, + analysis_queue: VecDeque::new(), + analysis_batch_total: 0, + analysis_batch_completed: 0, + analysis_handle: None, + analysis_progress_rx: None, + current_analysis_track: None, + } + } + + /// Set the audio device name. + pub fn with_audio_device(mut self, device_name: String) -> Self { + self.audio_config.device_name = device_name; + self + } + + /// Set the audio engine configuration. + pub fn with_audio_config(mut self, config: AudioEngineConfig) -> Self { + self.audio_config = config; + self + } + + /// Get the audio engine (if initialized). + pub fn audio_engine(&self) -> Option<&DjAudioEngine> { + self.audio_engine.as_ref() + } + + /// Get the audio engine mutably (if initialized). + pub fn audio_engine_mut(&mut self) -> Option<&mut DjAudioEngine> { + self.audio_engine.as_mut() + } + + /// Get the database (if initialized). + pub fn database(&self) -> Option>> { + self.database.clone() + } + + /// Get a reference to a deck. + pub fn deck(&self, id: DeckId) -> &Arc> { + match id { + DeckId::A => &self.deck_a, + DeckId::B => &self.deck_b, + } + } + + /// Get the current master deck. + pub fn master_deck(&self) -> Option { + self.master_deck + } + + /// Set the master deck. + pub fn set_master_deck(&mut self, deck: Option) { + // Clear master flag on old deck + if let Some(old_master) = self.master_deck { + self.deck(old_master).write().is_master = false; + } + + // Set master flag on new deck + if let Some(new_master) = deck { + self.deck(new_master).write().is_master = true; + } + + self.master_deck = deck; + } + + /// Translate a console command to an internal DJ command. + fn translate_console_command(&self, cmd: halo_core::ConsoleCommand) -> Option { + use halo_core::ConsoleCommand; + match cmd { + ConsoleCommand::DjImportFolder { path } => Some(DjCommand::ImportFolder { path }), + ConsoleCommand::DjLoadTrack { deck, track_id } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::LoadTrack { + deck: deck_id, + track_id: TrackId(track_id), + }) + } + ConsoleCommand::DjPlay { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::Play { deck: deck_id }) + } + ConsoleCommand::DjPause { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::Pause { deck: deck_id }) + } + ConsoleCommand::DjStop { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::Stop { deck: deck_id }) + } + ConsoleCommand::DjSetCue { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetCue { deck: deck_id }) + } + ConsoleCommand::DjJumpToCue { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::CuePlay { deck: deck_id }) + } + ConsoleCommand::DjCuePreview { deck, pressed } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::CuePreview { + deck: deck_id, + pressed, + }) + } + ConsoleCommand::DjSetHotCue { deck, slot } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetHotCue { + deck: deck_id, + slot, + }) + } + ConsoleCommand::DjJumpToHotCue { deck, slot } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::JumpToHotCue { + deck: deck_id, + slot, + }) + } + ConsoleCommand::DjSetPitch { deck, percent } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetPitch { + deck: deck_id, + percent, + }) + } + ConsoleCommand::DjToggleSync { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::ToggleSync { deck: deck_id }) + } + ConsoleCommand::DjSetMaster { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetMaster { deck: deck_id }) + } + ConsoleCommand::DjSeek { + deck, + position_seconds, + } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::Seek { + deck: deck_id, + position_seconds, + }) + } + ConsoleCommand::DjPreviousTrack { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::PreviousTrack { deck: deck_id }) + } + ConsoleCommand::DjNextTrack { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::NextTrack { deck: deck_id }) + } + ConsoleCommand::DjQueryLibrary => Some(DjCommand::GetAllTracks), + ConsoleCommand::DjToggleMasterTempo { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::ToggleMasterTempo { deck: deck_id }) + } + ConsoleCommand::DjSetTempoRange { deck, range } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + let tempo_range = match range { + 0 => TempoRange::Range6, + 1 => TempoRange::Range10, + 2 => TempoRange::Range16, + _ => TempoRange::Wide, + }; + Some(DjCommand::SetTempoRange { + deck: deck_id, + range: tempo_range, + }) + } + ConsoleCommand::DjSetLoop { deck, beat_count } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetLoop { + deck: deck_id, + beat_count, + }) + } + ConsoleCommand::DjToggleLoop { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::ToggleLoop { deck: deck_id }) + } + ConsoleCommand::DjHalveLoop { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::HalveLoop { deck: deck_id }) + } + ConsoleCommand::DjDoubleLoop { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::DoubleLoop { deck: deck_id }) + } + ConsoleCommand::DjReanalyzeTrack { track_id } => Some(DjCommand::ReanalyzeTrack { + track_id: TrackId(track_id), + }), + ConsoleCommand::DjUpdateTrackBpm { track_id, bpm } => Some(DjCommand::UpdateTrackBpm { + track_id: TrackId(track_id), + bpm, + }), + ConsoleCommand::DjDeleteTrack { track_id } => Some(DjCommand::DeleteTrack { + track_id: TrackId(track_id), + }), + ConsoleCommand::DjNudgeBeatGrid { deck, offset_ms } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::NudgeBeatGrid { + deck: deck_id, + offset_ms, + }) + } + ConsoleCommand::DjSetDownbeat { deck } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::SetDownbeat { deck: deck_id }) + } + ConsoleCommand::DjShiftBeatGrid { deck, beats } => { + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + Some(DjCommand::ShiftBeatGrid { + deck: deck_id, + beats, + }) + } + _ => None, + } + } + + /// Handle a DJ command. + fn handle_command(&mut self, command: DjCommand) { + match command { + DjCommand::Play { deck } => { + // Update deck state + { + let mut d = self.deck(deck).write(); + if d.state.has_track() { + d.state = DeckState::Playing; + } + } + // Control audio player - use quantized playback if sync is enabled + if let Some(engine) = &self.audio_engine { + let is_synced = engine.deck_player(deck).read().is_sync_enabled(); + let master = engine.master_deck(); + if is_synced && master.is_some() && master != Some(deck) { + engine.start_quantized_playback(deck); + } else { + engine.deck_player(deck).write().play(); + } + } + log::info!("Deck {} playing", deck); + } + DjCommand::Pause { deck } => { + // Update deck state + { + let mut d = self.deck(deck).write(); + if d.state == DeckState::Playing { + d.state = DeckState::Paused; + } + } + // Control audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().pause(); + } + log::info!("Deck {} paused", deck); + } + DjCommand::PlayPause { deck } => { + let should_play = { + let d = self.deck(deck).read(); + matches!(d.state, DeckState::Paused | DeckState::Stopped) + }; + + if should_play { + self.handle_command(DjCommand::Play { deck }); + } else { + self.handle_command(DjCommand::Pause { deck }); + } + } + DjCommand::Stop { deck } => { + // Update deck state + { + let mut d = self.deck(deck).write(); + if d.state.has_track() { + d.state = DeckState::Stopped; + d.position_seconds = 0.0; + d.position_beats = 0.0; + } + } + // Control audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().stop(); + } + log::info!("Deck {} stopped", deck); + } + DjCommand::SetCue { deck } => { + let position = if let Some(engine) = &self.audio_engine { + let player = engine.deck_player(deck).read(); + player.position_seconds() + } else { + self.deck(deck).read().position_seconds + }; + + // Update deck state + self.deck(deck).write().cue_point = Some(position); + + // Set cue in player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_cue_at(position); + } + log::info!("Deck {} cue set at {:.2}s", deck, position); + } + DjCommand::CuePreview { deck, pressed } => { + let mut d = self.deck(deck).write(); + if pressed { + if let Some(cue_point) = d.cue_point { + d.cue_preview_start = Some(d.position_seconds); + d.position_seconds = cue_point; + d.state = DeckState::Cueing; + + // Seek to cue and start playing + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + player.seek(cue_point); + player.play(); + } + log::info!("Deck {} cue preview started", deck); + } + } else if d.state == DeckState::Cueing { + if let Some(cue_point) = d.cue_point { + d.position_seconds = cue_point; + + // Pause and seek back to cue + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + player.pause(); + player.seek(cue_point); + } + } + d.state = DeckState::Paused; + d.cue_preview_start = None; + log::info!("Deck {} cue preview ended", deck); + } + } + DjCommand::SetPitch { deck, percent } => { + let adjusted_bpm = { + let mut d = self.deck(deck).write(); + d.pitch_percent = percent.clamp(-1.0, 1.0); + d.update_adjusted_bpm(); + d.adjusted_bpm + }; + + // Update playback rate based on pitch + if let Some(engine) = &self.audio_engine { + let playback_rate = adjusted_bpm / self.deck(deck).read().original_bpm; + engine + .deck_player(deck) + .write() + .set_playback_rate(playback_rate); + } + log::debug!( + "Deck {} pitch set to {:.1}% (BPM: {:.2})", + deck, + percent * 100.0, + adjusted_bpm + ); + } + DjCommand::SetTempoRange { deck, range } => { + let mut d = self.deck(deck).write(); + d.tempo_range = range; + d.update_adjusted_bpm(); + log::info!("Deck {} tempo range set to {:?}", deck, range); + } + DjCommand::SetMaster { deck } => { + self.set_master_deck(Some(deck)); + log::info!("Deck {} set as master", deck); + } + DjCommand::ToggleSync { deck } => { + let (sync_enabled, tempo_range) = { + let mut d = self.deck(deck).write(); + d.sync_enabled = !d.sync_enabled; + log::info!( + "Deck {} sync {}", + deck, + if d.sync_enabled { + "enabled" + } else { + "disabled" + } + ); + (d.sync_enabled, d.tempo_range) + }; + + if let Some(engine) = &self.audio_engine { + if sync_enabled { + // When sync is enabled, immediately sync to master deck's BPM + if engine.sync_to_master(deck, tempo_range) { + // Update UI state with new pitch/BPM + let player = engine.deck_player(deck).read(); + let mut d = self.deck(deck).write(); + d.pitch_percent = player.playback_rate() - 1.0; + if let Some(bpm) = player.effective_bpm() { + d.adjusted_bpm = bpm; + } + log::info!("Deck {} synced to master BPM", deck); + } else { + log::warn!("Deck {} failed to sync (no master or out of range)", deck); + } + } else { + // When sync is disabled, stop continuous sync + engine.disable_sync(deck); + } + } + } + DjCommand::SetHotCue { deck, slot } => { + if slot < 4 { + let position = if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().position_seconds() + } else { + self.deck(deck).read().position_seconds + }; + + self.deck(deck).write().set_hot_cue(slot, position); + log::info!("Deck {} hot cue {} set at {:.2}s", deck, slot, position); + } + } + DjCommand::JumpToHotCue { deck, slot } => { + if slot < 4 { + let position = { + let d = self.deck(deck).read(); + d.hot_cues[slot as usize] + .as_ref() + .map(|cue| cue.position_seconds) + }; + + if let Some(pos) = position { + self.deck(deck).write().position_seconds = pos; + self.deck(deck).write().update_beat_position(); + + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().seek(pos); + } + log::info!("Deck {} jumped to hot cue {}", deck, slot); + } + } + } + DjCommand::ClearHotCue { deck, slot } => { + if slot < 4 { + self.deck(deck).write().clear_hot_cue(slot); + log::info!("Deck {} hot cue {} cleared", deck, slot); + } + } + DjCommand::Seek { + deck, + position_seconds, + } => { + let clamped_position = { + let d = self.deck(deck).read(); + if let Some(track) = &d.loaded_track { + position_seconds.clamp(0.0, track.duration_seconds) + } else { + return; + } + }; + + { + let mut d = self.deck(deck).write(); + d.position_seconds = clamped_position; + d.update_beat_position(); + } + + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().seek(clamped_position); + } + } + DjCommand::EjectTrack { deck } => { + self.deck(deck).write().eject(); + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().eject(); + } + log::info!("Deck {} ejected", deck); + } + DjCommand::LoadTrack { deck, track_id } => { + log::info!( + "DJ Module: Processing LoadTrack command - deck={:?}, track_id={:?}", + deck, + track_id + ); + self.load_track_to_deck(deck, track_id); + } + DjCommand::ImportFolder { path } => { + self.import_folder(path); + } + DjCommand::GetAllTracks => { + // Handled separately in run loop to send response + } + DjCommand::SearchLibrary { query } => { + if let Some(db) = &self.database { + let db = db.lock().unwrap(); + match db.search_tracks(&query) { + Ok(tracks) => { + log::info!("Found {} tracks matching '{}'", tracks.len(), query); + } + Err(e) => { + log::error!("Search failed: {}", e); + } + } + } + } + DjCommand::AnalyzeTrack { track_id } => { + log::info!("Track analysis not yet implemented for track {}", track_id); + } + DjCommand::ReanalyzeTrack { track_id } => { + self.reanalyze_track(track_id); + } + DjCommand::UpdateTrackBpm { track_id, bpm } => { + self.update_track_bpm(track_id, bpm); + } + DjCommand::DeleteTrack { track_id } => { + self.delete_track(track_id); + } + // Handle remaining commands + _ => { + log::warn!("Unhandled DJ command: {:?}", command); + } + } + } + + /// Load a track from the library onto a deck. + fn load_track_to_deck(&mut self, deck: DeckId, track_id: TrackId) { + log::info!( + "DJ Module: load_track_to_deck called - deck={:?}, track_id={:?}", + deck, + track_id + ); + + let Some(db) = &self.database else { + log::error!("Database not initialized"); + return; + }; + + // Get track and related data from database + let (track, hot_cues, beat_grid) = { + let db = db.lock().unwrap(); + + let track = match db.get_track(track_id) { + Ok(Some(track)) => track, + Ok(None) => { + log::error!("Track {} not found in library", track_id); + return; + } + Err(e) => { + log::error!("Failed to load track {}: {}", track_id, e); + return; + } + }; + + let hot_cues = db.get_hot_cues(track_id).unwrap_or_default(); + let beat_grid = db.get_beat_grid(track_id).ok().flatten(); + + (track, hot_cues, beat_grid) + }; + + // Load track into deck state + { + let mut d = self.deck(deck).write(); + d.loaded_track = Some(track.clone()); + d.state = DeckState::Stopped; + d.position_seconds = 0.0; + d.position_beats = 0.0; + // Reset pitch to 0 when loading a new track + d.pitch_percent = 0.0; + // Always use track.bpm as the authoritative BPM source + d.original_bpm = track.bpm.unwrap_or(120.0); + d.adjusted_bpm = d.original_bpm; + + // Load hot cues + for cue in hot_cues { + let slot = cue.slot; + if slot < 4 { + d.hot_cues[slot as usize] = Some(cue); + } + } + + // Load beat grid into deck state, using track.bpm for beat positions + if let Some(mut bg) = beat_grid.clone() { + // Recalculate beat positions from first_beat_offset and track.bpm + let offset_seconds = bg.first_beat_offset_ms / 1000.0; + let beat_interval = 60.0 / d.original_bpm; + let mut positions = Vec::new(); + let mut pos = offset_seconds; + while pos < track.duration_seconds { + positions.push(pos); + pos += beat_interval; + } + bg.beat_positions = positions; + d.beat_grid = Some(bg); + } else { + d.beat_grid = None; + } + } + + // Load audio file into player + let original_bpm = self.deck(deck).read().original_bpm; + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + if let Err(e) = player.load(&track.file_path) { + log::error!("Failed to load audio file: {}", e); + return; + } + // Load beat grid into player with track's BPM + let deck_beat_grid = self.deck(deck).read().beat_grid.clone(); + if let Some(bg) = deck_beat_grid { + player.set_beat_grid(bg, original_bpm); + } + } + + log::info!( + "Deck {} loaded: {} - {}", + deck, + track.artist.as_deref().unwrap_or("Unknown"), + track.title + ); + } + + /// Get all tracks from the library for UI display. + fn get_all_tracks_for_ui(&self) -> Option> { + let db = self.database.as_ref()?; + let db = db.lock().unwrap(); + + match db.get_all_tracks() { + Ok(tracks) => { + let track_infos: Vec = tracks + .into_iter() + .map(|t| halo_core::DjTrackInfo { + id: t.id.0, + title: t.title, + artist: t.artist, + duration_seconds: t.duration_seconds, + bpm: t.bpm, + file_path: t.file_path, + }) + .collect(); + log::info!("Returning {} tracks to UI", track_infos.len()); + Some(track_infos) + } + Err(e) => { + log::error!("Failed to get tracks: {}", e); + None + } + } + } + + /// Re-analyze a track's BPM. + fn reanalyze_track(&mut self, track_id: TrackId) { + let Some(db) = &self.database else { + log::error!("Database not initialized"); + return; + }; + + // Get track info from database + let (file_path, track_name) = { + let db = db.lock().unwrap(); + match db.get_track(track_id) { + Ok(Some(track)) => (track.file_path.clone(), track.title.clone()), + Ok(None) => { + log::error!("Track {} not found", track_id); + return; + } + Err(e) => { + log::error!("Failed to get track {}: {}", track_id, e); + return; + } + } + }; + + // Queue for background analysis + log::info!( + "Queueing track {} ({}) for re-analysis", + track_id, + track_name + ); + + // If no analysis is in progress, start a fresh batch + // Otherwise, add to existing batch + if self.analysis_queue.is_empty() && !self.is_analysis_running() { + self.analysis_batch_completed = 0; + self.analysis_batch_total = 1; + } else { + self.analysis_batch_total += 1; + } + + self.analysis_queue.push_back(PendingAnalysis { + track_id, + file_path: PathBuf::from(file_path), + track_name, + }); + } + + /// Update a track's BPM manually. + fn update_track_bpm(&mut self, track_id: TrackId, bpm: f64) { + let Some(db) = &self.database else { + log::error!("Database not initialized"); + return; + }; + + let db = db.lock().unwrap(); + match db.update_track_bpm(track_id, bpm) { + Ok(_) => { + log::info!("Updated track {} BPM to {:.1}", track_id, bpm); + } + Err(e) => { + log::error!("Failed to update track {} BPM: {}", track_id, e); + } + } + } + + /// Delete a track from the library. + fn delete_track(&mut self, track_id: TrackId) { + let Some(db) = &self.database else { + log::error!("Database not initialized"); + return; + }; + + let db = db.lock().unwrap(); + match db.delete_track(track_id) { + Ok(_) => { + log::info!("Deleted track {} from library", track_id); + } + Err(e) => { + log::error!("Failed to delete track {}: {}", track_id, e); + } + } + } + + /// Import all audio files from a folder into the library. + /// Metadata is extracted immediately; BPM analysis is queued for background processing. + fn import_folder(&mut self, path: PathBuf) { + let Some(db) = &self.database else { + log::error!("Database not initialized, cannot import folder"); + return; + }; + + log::info!("Importing folder (metadata only): {:?}", path); + + // Scan directory for audio files + let audio_files = scan_directory_for_audio(&path, true); + let total_files = audio_files.len(); + log::info!("Found {} audio files to import", total_files); + + if total_files == 0 { + return; + } + + let db_guard = db.lock().unwrap(); + + let mut imported_count = 0; + let mut skipped_count = 0; + let mut tracks_to_analyze = Vec::new(); + + // Phase 1: Fast metadata import (no analysis) + for file_path in audio_files { + match import_file_metadata_only(&file_path, &db_guard) { + Ok(track) => { + // Check if track needs analysis (no BPM yet) + if track.bpm.is_none() { + tracks_to_analyze.push(PendingAnalysis { + track_id: track.id, + file_path: PathBuf::from(&track.file_path), + track_name: track.title.clone(), + }); + } else { + skipped_count += 1; // Already analyzed + } + imported_count += 1; + } + Err(e) => { + log::warn!("Failed to import file {:?}: {}", file_path, e); + } + } + } + + drop(db_guard); // Release lock before modifying self + + // Phase 2: Queue tracks for background analysis + let tracks_to_analyze_count = tracks_to_analyze.len(); + if !tracks_to_analyze.is_empty() { + // If no analysis is in progress, start a fresh batch + // Otherwise, add to existing batch + if self.analysis_queue.is_empty() && !self.is_analysis_running() { + self.analysis_batch_total = tracks_to_analyze_count; + self.analysis_batch_completed = 0; + } else { + self.analysis_batch_total += tracks_to_analyze_count; + } + self.analysis_queue.extend(tracks_to_analyze); + log::info!( + "Queued {} tracks for background analysis (total: {})", + tracks_to_analyze_count, + self.analysis_batch_total + ); + } + + log::info!( + "Import complete: {} imported, {} already analyzed, {} queued for analysis", + imported_count, + skipped_count, + tracks_to_analyze_count + ); + } + + /// Check if there are tracks in the analysis queue. + fn has_pending_analysis(&self) -> bool { + !self.analysis_queue.is_empty() + } + + /// Get the current analysis progress info for status display. + fn analysis_progress(&self) -> Option<(String, usize, usize)> { + // If currently analyzing, show that track + if let Some((_, track_name)) = &self.current_analysis_track { + return Some(( + track_name.clone(), + self.analysis_batch_completed + 1, + self.analysis_batch_total, + )); + } + // Otherwise show next track in queue + self.analysis_queue.front().map(|pending| { + ( + pending.track_name.clone(), + self.analysis_batch_completed + 1, + self.analysis_batch_total, + ) + }) + } + + /// Check if an analysis task is currently running. + fn is_analysis_running(&self) -> bool { + self.analysis_handle.is_some() + } + + /// Start analyzing the next track in the queue (non-blocking). + /// Returns Some((track_id, track_name)) if analysis was started. + fn start_next_analysis(&mut self) -> Option<(TrackId, String)> { + // Don't start if already analyzing + if self.analysis_handle.is_some() { + return None; + } + + let pending = self.analysis_queue.pop_front()?; + let track_id = pending.track_id; + let track_name = pending.track_name.clone(); + let file_path = pending.file_path.clone(); + + log::info!( + "Starting background analysis for: {} ({}/{})", + track_name, + self.analysis_batch_completed + 1, + self.analysis_batch_total + ); + + // Create channel for streaming waveform progress + let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel(); + self.analysis_progress_rx = Some(progress_rx); + + // Store current track info + self.current_analysis_track = Some((track_id, track_name.clone())); + + // Spawn analysis on blocking thread pool + self.analysis_handle = Some(tokio::task::spawn_blocking(move || { + let config = AnalysisConfig::default(); + analyze_file_streaming( + &file_path, + track_id, + &config, + 100, // Send progress every 100 waveform samples + |samples, progress| { + let _ = progress_tx.send((samples, progress)); + }, + ) + .ok() + })); + + Some((track_id, track_name)) + } +} + +impl Default for DjModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AsyncModule for DjModule { + fn id(&self) -> ModuleId { + ModuleId::Dj + } + + async fn initialize(&mut self) -> Result<(), Box> { + log::info!("Initializing DJ module"); + log::info!("Library path: {:?}", self.library_path); + log::info!("Audio device: {}", self.audio_config.device_name); + + // Ensure library directory exists + if let Some(parent) = self.library_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Initialize database + let db = LibraryDatabase::open(&self.library_path)?; + self.database = Some(Arc::new(Mutex::new(db))); + log::info!("Library database opened"); + + // Initialize audio engine + let mut engine = DjAudioEngine::new(self.audio_config.clone()); + if let Err(e) = engine.start() { + log::error!("Failed to start audio engine: {}", e); + // Continue without audio engine - useful for testing + } else { + log::info!("Audio engine started"); + } + self.audio_engine = Some(engine); + + log::info!("DJ module initialized"); + Ok(()) + } + + async fn run( + &mut self, + mut rx: mpsc::Receiver, + tx: mpsc::Sender, + ) -> Result<(), Box> { + log::info!("DJ module running"); + + // Rhythm sync update interval (roughly 30Hz for smooth phase tracking) + let mut rhythm_interval = tokio::time::interval(std::time::Duration::from_millis(33)); + // Track last beat number to detect beat triggers + let mut last_beat_a: Option = None; + let mut last_beat_b: Option = None; + + loop { + tokio::select! { + Some(event) = rx.recv() => { + match event { + ModuleEvent::Shutdown => { + log::info!("DJ module received shutdown"); + break; + } + // Handle MIDI input via Z1 mapping + ModuleEvent::MidiInput(midi_msg) => { + log::debug!("DJ module received MIDI: {:?}", midi_msg); + + // Translate MIDI to DJ command via Z1 mapping + let command = match midi_msg { + MidiMessage::NoteOn(note, velocity) => { + Z1Mapping::translate_note_on(note, velocity) + } + MidiMessage::NoteOff(note) => { + Z1Mapping::translate_note_off(note) + } + MidiMessage::ControlChange(cc, value) => { + Z1Mapping::translate_cc(cc, value) + } + MidiMessage::Clock => { + // MIDI clock messages are handled by rhythm sync + None + } + }; + + // Execute the command if one was generated + if let Some(cmd) = command { + log::debug!("Executing DJ command from MIDI: {:?}", cmd); + self.handle_command(cmd); + } + } + // Handle DJ commands from console + ModuleEvent::DjCommand(console_cmd) => { + eprintln!("DEBUG: DJ module received command: {:?}", console_cmd); + log::debug!("DJ module received command: {:?}", console_cmd); + // Translate ConsoleCommand to internal DjCommand + let translated = self.translate_console_command(console_cmd); + eprintln!("DEBUG: Translated command: {:?}", translated); + if let Some(cmd) = translated { + // Special handling for commands that need to send responses + match cmd { + DjCommand::GetAllTracks => { + if let Some(tracks) = self.get_all_tracks_for_ui() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLibraryTracks(tracks) + )).await; + } + } + DjCommand::LoadTrack { deck, track_id } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + let tid = track_id.0; + eprintln!("DEBUG: Calling handle_command for LoadTrack"); + self.handle_command(DjCommand::LoadTrack { deck, track_id }); + // Get track info (drop lock before await) + let track_info = { + let deck_state = self.deck(deck).read(); + deck_state.loaded_track.as_ref().map(|t| { + (t.title.clone(), t.artist.clone(), t.duration_seconds, t.bpm) + }) + }; + // Send deck loaded event + if let Some((title, artist, duration_seconds, bpm)) = track_info.clone() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckLoaded { + deck: deck_num, + track_id: tid, + title, + artist, + duration_seconds, + bpm, + } + )).await; + eprintln!("DEBUG: Sent DjDeckLoaded event for deck {}", deck_num); + + // Send pitch changed event (pitch reset to 0 on load) + let (tempo_range, adjusted_bpm) = { + let d = self.deck(deck).read(); + (d.tempo_range.to_u8(), d.adjusted_bpm) + }; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjPitchChanged { + deck: deck_num, + pitch_percent: 0.0, + tempo_range, + adjusted_bpm, + } + )).await; + } + // Check if waveform exists in database + let existing_waveform = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_waveform(track_id).ok().flatten() + } else { + None + } + } else { + None + }; + + // Check if waveform exists with frequency bands (colored waveform) + let use_cached_waveform = existing_waveform + .as_ref() + .map(|w| w.frequency_bands.is_some()) + .unwrap_or(false); + + if let Some(waveform) = existing_waveform.filter(|_| use_cached_waveform) { + // Waveform exists with colored data - send immediately + let sample_count = waveform.sample_count; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjWaveformLoaded { + deck: deck_num, + samples: Arc::new(waveform.samples), + frequency_bands: waveform.frequency_bands.map(|bands| { + Arc::new(bands.iter().map(|b| b.as_tuple()).collect()) + }), + duration_seconds: waveform.duration_seconds, + } + )).await; + eprintln!("DEBUG: Sent cached DjWaveformLoaded event for deck {} ({} samples)", deck_num, sample_count); + + // Load beat grid from database and auto-cue to first beat + let beat_grid = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_beat_grid(track_id).ok().flatten() + } else { + None + } + } else { + None + }; + + if let Some(mut beat_grid) = beat_grid { + // Get track's authoritative BPM + let track_bpm = { + let d = self.deck(deck).read(); + d.loaded_track + .as_ref() + .and_then(|t| t.bpm) + .unwrap_or(120.0) + }; + + // Recalculate beat positions from first_beat_offset and track.bpm + let duration = { + let d = self.deck(deck).read(); + d.loaded_track + .as_ref() + .map(|t| t.duration_seconds) + .unwrap_or(0.0) + }; + let offset_seconds = beat_grid.first_beat_offset_ms / 1000.0; + let beat_interval = 60.0 / track_bpm; + let mut positions = Vec::new(); + let mut pos = offset_seconds; + while pos < duration { + positions.push(pos); + pos += beat_interval; + } + beat_grid.beat_positions = positions; + + // Store beat grid in deck state + { + let mut deck_state = self.deck(deck).write(); + deck_state.beat_grid = Some(beat_grid.clone()); + deck_state.original_bpm = track_bpm; + deck_state.adjusted_bpm = track_bpm; + } + + // Auto-cue to first beat + let first_beat_seconds = beat_grid.first_beat_offset_ms / 1000.0; + { + let mut deck_state = self.deck(deck).write(); + deck_state.cue_point = Some(first_beat_seconds); + deck_state.position_seconds = first_beat_seconds; + } + + // Set beat grid and seek audio engine to first beat + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + player.set_beat_grid(beat_grid.clone(), track_bpm); + player.seek(first_beat_seconds); + } + + // Send cue point event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjCuePointSet { + deck: deck_num, + position_seconds: first_beat_seconds, + } + )).await; + + // Send beat grid loaded event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions: beat_grid.beat_positions.clone(), + first_beat_offset: first_beat_seconds, + bpm: track_bpm, + is_nudge: false, + } + )).await; + eprintln!("DEBUG: Sent DjBeatGridLoaded event for deck {} ({} beats)", deck_num, beat_grid.beat_positions.len()); + } + } else { + // No waveform or outdated version - spawn background analysis task + let (file_path, track_title) = { + let deck_state = self.deck(deck).read(); + ( + deck_state.loaded_track.as_ref().map(|t| t.file_path.clone()), + deck_state.loaded_track.as_ref().map(|t| t.title.clone()).unwrap_or_else(|| "Unknown".to_string()), + ) + }; + + if let Some(path) = file_path { + eprintln!("DEBUG: Spawning background analysis for colored waveform: {}", path); + let tx_clone = tx.clone(); + let db_clone = self.database.clone(); + let deck_arc = self.deck(deck).clone(); + let player_arc = self.audio_engine.as_ref().map(|e| e.deck_player(deck).clone()); + + // Send analysis progress event to show in footer + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisProgress { + track_id: track_id.0, + track_name: track_title.clone(), + current: 1, + total: 1, + progress: 0.0, + } + )).await; + + tokio::spawn(async move { + // Create channel for progress updates from blocking analysis + let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::<(Vec, f32)>(); + + // Spawn blocking analysis in a separate thread + let analysis_handle = { + let progress_tx = progress_tx.clone(); + let path = path.clone(); + tokio::task::spawn_blocking(move || { + let config = AnalysisConfig::default(); + analyze_file_streaming( + &path, + track_id, + &config, + 100, // Send progress every 100 samples (10 updates total) + |samples, progress| { + let _ = progress_tx.send((samples, progress)); + }, + ) + }) + }; + + // Drop our copy of progress_tx so channel closes when analysis completes + drop(progress_tx); + + // Forward progress updates as they arrive + while let Some((samples, progress)) = progress_rx.recv().await { + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::DjWaveformProgress { + deck: deck_num, + samples: Arc::new(samples), + frequency_bands: None, // Legacy analysis without color data + progress, + } + )).await; + } + + // Wait for analysis to complete + match analysis_handle.await { + Ok(Ok(result)) => { + // Save to database + if let Some(db) = db_clone { + if let Ok(db_guard) = db.lock() { + let _ = db_guard.save_waveform(&result.waveform); + let _ = db_guard.save_beat_grid(&result.beat_grid); + eprintln!("DEBUG: Saved analysis results to database"); + } + } + + // Calculate first beat position + let first_beat_seconds = result.beat_grid.first_beat_offset_ms / 1000.0; + let beat_positions = result.beat_grid.beat_positions.clone(); + let bpm = result.bpm; + + // Update deck with beat grid and auto-cue to first beat + { + let mut deck_state = deck_arc.write(); + deck_state.beat_grid = Some(result.beat_grid.clone()); + deck_state.cue_point = Some(first_beat_seconds); + deck_state.position_seconds = first_beat_seconds; + deck_state.original_bpm = bpm; + deck_state.adjusted_bpm = bpm; + } + + // Also set beat grid on DeckPlayer for sync/BPM calculations + if let Some(player) = &player_arc { + let mut player = player.write(); + player.set_beat_grid(result.beat_grid, bpm); + player.seek(first_beat_seconds); + } + + // Send cue point event + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::DjCuePointSet { + deck: deck_num, + position_seconds: first_beat_seconds, + } + )).await; + + // Send beat grid loaded event + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions, + first_beat_offset: first_beat_seconds, + bpm, + is_nudge: false, + } + )).await; + eprintln!("DEBUG: Sent DjBeatGridLoaded event for deck {} after analysis", deck_num); + + // Send final waveform + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::DjWaveformLoaded { + deck: deck_num, + samples: Arc::new(result.waveform.samples), + frequency_bands: result.waveform.frequency_bands.map(|bands| { + Arc::new(bands.iter().map(|b| b.as_tuple()).collect()) + }), + duration_seconds: result.waveform.duration_seconds, + } + )).await; + + // Clear status message + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::StatusClear + )).await; + eprintln!("DEBUG: Background analysis complete for deck {}", deck_num); + } + Ok(Err(e)) => { + // Clear status message on error too + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::StatusClear + )).await; + eprintln!("DEBUG: Background analysis failed: {}", e); + log::error!("Background analysis failed: {}", e); + } + Err(e) => { + // Clear status message on panic too + let _ = tx_clone.send(ModuleMessage::Event( + ModuleEvent::StatusClear + )).await; + eprintln!("DEBUG: Analysis task panicked: {}", e); + log::error!("Analysis task panicked: {}", e); + } + } + }); + } + } + } + DjCommand::Play { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::Play { deck }); + // Get current state after play command + let position = { + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().position_seconds() + } else { + self.deck(deck).read().position_seconds + } + }; + let adjusted_bpm = self.deck(deck).read().adjusted_bpm; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing: true, + position_seconds: position, + bpm: Some(adjusted_bpm), + } + )).await; + eprintln!("DEBUG: Sent DjDeckStateChanged (playing) for deck {}", deck_num); + } + DjCommand::Pause { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::Pause { deck }); + // Get current state after pause command + let position = { + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().position_seconds() + } else { + self.deck(deck).read().position_seconds + } + }; + let adjusted_bpm = self.deck(deck).read().adjusted_bpm; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing: false, + position_seconds: position, + bpm: Some(adjusted_bpm), + } + )).await; + eprintln!("DEBUG: Sent DjDeckStateChanged (paused) for deck {}", deck_num); + } + DjCommand::Stop { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::Stop { deck }); + let adjusted_bpm = self.deck(deck).read().adjusted_bpm; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing: false, + position_seconds: 0.0, + bpm: Some(adjusted_bpm), + } + )).await; + } + DjCommand::SetCue { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::SetCue { deck }); + // Get the cue position that was just set + let cue_position = self.deck(deck).read().cue_point; + if let Some(position_seconds) = cue_position { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjCuePointSet { + deck: deck_num, + position_seconds, + } + )).await; + eprintln!("DEBUG: Sent DjCuePointSet for deck {} at {:.2}s", deck_num, position_seconds); + } + } + DjCommand::Seek { deck, position_seconds } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::Seek { deck, position_seconds }); + // Get current state after seek + let is_playing = { + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().state() == PlayerState::Playing + } else { + self.deck(deck).read().state == DeckState::Playing + } + }; + let adjusted_bpm = self.deck(deck).read().adjusted_bpm; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing, + position_seconds, + bpm: Some(adjusted_bpm), + } + )).await; + } + DjCommand::CuePreview { deck, pressed } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + self.handle_command(DjCommand::CuePreview { deck, pressed }); + // Get state after cue preview action + // When releasing (pressed=false), use cue_point directly since seek may not have updated player yet + let (is_playing, position) = { + let d = self.deck(deck).read(); + if pressed { + // Starting preview - playing from cue point + (true, d.cue_point.unwrap_or(0.0)) + } else { + // Ending preview - stopped at cue point + (false, d.cue_point.unwrap_or(d.position_seconds)) + } + }; + let adjusted_bpm = self.deck(deck).read().adjusted_bpm; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing, + position_seconds: position, + bpm: Some(adjusted_bpm), + } + )).await; + } + DjCommand::PreviousTrack { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Stop playback first + self.handle_command(DjCommand::Pause { deck }); + let (pause_position, adjusted_bpm) = if let Some(engine) = &self.audio_engine { + let pos = engine.deck_player(deck).read().position_seconds(); + let bpm = self.deck(deck).read().adjusted_bpm; + (pos, bpm) + } else { + let deck_state = self.deck(deck).read(); + (deck_state.position_seconds, deck_state.adjusted_bpm) + }; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing: false, + position_seconds: pause_position, + bpm: Some(adjusted_bpm), + } + )).await; + + // Get current position and loaded track ID + let (position, current_track_id) = { + let deck_state = self.deck(deck).read(); + let pos = if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().position_seconds() + } else { + deck_state.position_seconds + }; + let track_id = deck_state.loaded_track.as_ref().map(|t| t.id); + (pos, track_id) + }; + + // Threshold: if position > 0.5s, seek to start + if position > 0.5 { + self.handle_command(DjCommand::Seek { deck, position_seconds: 0.0 }); + let (is_playing, seek_bpm) = { + if let Some(engine) = &self.audio_engine { + let playing = engine.deck_player(deck).read().state() == PlayerState::Playing; + let bpm = self.deck(deck).read().adjusted_bpm; + (playing, bpm) + } else { + let deck_state = self.deck(deck).read(); + (deck_state.state == DeckState::Playing, deck_state.adjusted_bpm) + } + }; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing, + position_seconds: 0.0, + bpm: Some(seek_bpm), + } + )).await; + eprintln!("DEBUG: PreviousTrack: Seeked to start of deck {}", deck_num); + } else if let Some(track_id) = current_track_id { + // Already at start, try to load previous track + let prev_track = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_adjacent_track(track_id, false).ok().flatten() + } else { + None + } + } else { + None + }; + + if let Some(track) = prev_track { + let new_track_id = track.id; + eprintln!("DEBUG: PreviousTrack: Loading previous track: {}", track.title); + + // Load the track using handle_command + self.handle_command(DjCommand::LoadTrack { deck, track_id: new_track_id }); + + // Get track info and send loaded event + let track_info = { + let deck_state = self.deck(deck).read(); + deck_state.loaded_track.as_ref().map(|t| { + (t.title.clone(), t.artist.clone(), t.duration_seconds, t.bpm) + }) + }; + if let Some((title, artist, duration_seconds, bpm)) = track_info { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckLoaded { + deck: deck_num, + track_id: new_track_id.0, + title, + artist, + duration_seconds, + bpm, + } + )).await; + } + + // Check for cached waveform + let existing_waveform = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_waveform(new_track_id).ok().flatten() + } else { + None + } + } else { + None + }; + + // Only use waveform if it has colored data + if let Some(waveform) = existing_waveform.filter(|w| w.frequency_bands.is_some()) { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjWaveformLoaded { + deck: deck_num, + samples: Arc::new(waveform.samples), + frequency_bands: waveform.frequency_bands.map(|bands| { + Arc::new(bands.iter().map(|b| b.as_tuple()).collect()) + }), + duration_seconds: waveform.duration_seconds, + } + )).await; + } + // Note: If waveform is old/missing, it will be analyzed when LoadTrack is called + } else { + eprintln!("DEBUG: PreviousTrack: No previous track available"); + } + } + } + DjCommand::NextTrack { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Stop playback first + self.handle_command(DjCommand::Pause { deck }); + let (pause_position, next_track_bpm) = if let Some(engine) = &self.audio_engine { + let pos = engine.deck_player(deck).read().position_seconds(); + let bpm = self.deck(deck).read().adjusted_bpm; + (pos, bpm) + } else { + let deck_state = self.deck(deck).read(); + (deck_state.position_seconds, deck_state.adjusted_bpm) + }; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckStateChanged { + deck: deck_num, + is_playing: false, + position_seconds: pause_position, + bpm: Some(next_track_bpm), + } + )).await; + + // Get loaded track ID + let current_track_id = { + let deck_state = self.deck(deck).read(); + deck_state.loaded_track.as_ref().map(|t| t.id) + }; + + if let Some(track_id) = current_track_id { + // Load next track + let next_track = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_adjacent_track(track_id, true).ok().flatten() + } else { + None + } + } else { + None + }; + + if let Some(track) = next_track { + let new_track_id = track.id; + eprintln!("DEBUG: NextTrack: Loading next track: {}", track.title); + + // Load the track using handle_command + self.handle_command(DjCommand::LoadTrack { deck, track_id: new_track_id }); + + // Get track info and send loaded event + let track_info = { + let deck_state = self.deck(deck).read(); + deck_state.loaded_track.as_ref().map(|t| { + (t.title.clone(), t.artist.clone(), t.duration_seconds, t.bpm) + }) + }; + if let Some((title, artist, duration_seconds, bpm)) = track_info { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjDeckLoaded { + deck: deck_num, + track_id: new_track_id.0, + title, + artist, + duration_seconds, + bpm, + } + )).await; + } + + // Check for cached waveform + let existing_waveform = if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + db_guard.get_waveform(new_track_id).ok().flatten() + } else { + None + } + } else { + None + }; + + // Only use waveform if it has colored data + if let Some(waveform) = existing_waveform.filter(|w| w.frequency_bands.is_some()) { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjWaveformLoaded { + deck: deck_num, + samples: Arc::new(waveform.samples), + frequency_bands: waveform.frequency_bands.map(|bands| { + Arc::new(bands.iter().map(|b| b.as_tuple()).collect()) + }), + duration_seconds: waveform.duration_seconds, + } + )).await; + } + // Note: If waveform is old/missing, it will be analyzed when LoadTrack is called + } else { + eprintln!("DEBUG: NextTrack: No next track available"); + } + } + } + DjCommand::ToggleMasterTempo { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Toggle master tempo on the deck state + let new_mode = { + let mut d = self.deck(deck).write(); + d.master_tempo = match d.master_tempo { + MasterTempoMode::Off => MasterTempoMode::On, + MasterTempoMode::On => MasterTempoMode::Off, + }; + d.master_tempo + }; + + // Toggle on the audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().toggle_master_tempo(); + } + + let enabled = new_mode == MasterTempoMode::On; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjMasterTempoChanged { + deck: deck_num, + enabled, + } + )).await; + log::info!("Deck {} Master Tempo {}", deck, if enabled { "ON" } else { "OFF" }); + } + DjCommand::SetTempoRange { deck, range } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Update deck state + { + let mut d = self.deck(deck).write(); + d.tempo_range = range; + d.update_adjusted_bpm(); + } + + // Update audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_tempo_range(range); + } + + let range_value = match range { + TempoRange::Range6 => 0, + TempoRange::Range10 => 1, + TempoRange::Range16 => 2, + TempoRange::Wide => 3, + }; + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjTempoRangeChanged { + deck: deck_num, + range: range_value, + } + )).await; + log::info!("Deck {} tempo range set to {:?}", deck, range); + } + DjCommand::SetLoop { deck, beat_count } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + let beat_count_f64 = beat_count as f64; + + // Get current position and beat grid + let (loop_in, loop_out) = { + let d = self.deck(deck).read(); + + // Get current position from audio engine + let current_pos = if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).read().position_seconds() + } else { + d.position_seconds + }; + + // Quantize to nearest beat + let bpm = d.original_bpm; + if let Some(beat_grid) = &d.beat_grid { + let loop_in = beat_grid.nearest_beat(current_pos, bpm); + let loop_out = BeatGrid::beat_position_after(loop_in, beat_count_f64, bpm); + (loop_in, loop_out) + } else { + // No beat grid - use current position without quantization + let beat_duration = 60.0 / bpm.max(1.0); + let loop_out = current_pos + (beat_count_f64 * beat_duration); + (current_pos, loop_out) + } + }; + + // Update deck state + { + let mut d = self.deck(deck).write(); + d.loop_state.loop_in = Some(loop_in); + d.loop_state.loop_out = Some(loop_out); + d.loop_state.active = true; + d.loop_state.beat_count = beat_count_f64; + } + + // Update audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_loop(loop_in, loop_out); + } + + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLoopStateChanged { + deck: deck_num, + loop_in: Some(loop_in), + loop_out: Some(loop_out), + active: true, + beat_count: beat_count_f64, + } + )).await; + log::info!("Deck {}: Set {}-beat loop from {:.2}s to {:.2}s", deck, beat_count_f64, loop_in, loop_out); + } + DjCommand::ToggleLoop { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Get current loop state + let (loop_in, loop_out, new_active, beat_count) = { + let mut d = self.deck(deck).write(); + if d.loop_state.is_defined() { + d.loop_state.active = !d.loop_state.active; + (d.loop_state.loop_in, d.loop_state.loop_out, d.loop_state.active, d.loop_state.beat_count) + } else { + (None, None, false, 0.0) + } + }; + + // Update audio player + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_loop_active(new_active); + } + + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLoopStateChanged { + deck: deck_num, + loop_in, + loop_out, + active: new_active, + beat_count, + } + )).await; + log::info!("Deck {}: Loop {}", deck, if new_active { "enabled" } else { "disabled" }); + } + DjCommand::HalveLoop { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Calculate halved loop (min 1/32 beat) + let (loop_in, new_loop_out, new_beat_count, is_active) = { + let mut d = self.deck(deck).write(); + if let (Some(in_pt), Some(out_pt)) = (d.loop_state.loop_in, d.loop_state.loop_out) { + let length = out_pt - in_pt; + let new_length = length / 2.0; + let new_beat_count = (d.loop_state.beat_count / 2.0).max(MIN_LOOP_BEATS); + let new_out = in_pt + new_length; + + // Update state + d.loop_state.loop_out = Some(new_out); + d.loop_state.beat_count = new_beat_count; + + (Some(in_pt), Some(new_out), new_beat_count, d.loop_state.active) + } else { + (None, None, 0.0, false) + } + }; + + // Update audio player if loop is defined + if let (Some(loop_in_val), Some(loop_out_val)) = (loop_in, new_loop_out) { + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_loop(loop_in_val, loop_out_val); + } + } + + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLoopStateChanged { + deck: deck_num, + loop_in, + loop_out: new_loop_out, + active: is_active, + beat_count: new_beat_count, + } + )).await; + log::info!("Deck {}: Halved loop to {} beats", deck, new_beat_count); + } + DjCommand::DoubleLoop { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Calculate doubled loop (max 512 beats) + let (loop_in, new_loop_out, new_beat_count, is_active) = { + let mut d = self.deck(deck).write(); + if let (Some(in_pt), Some(out_pt)) = (d.loop_state.loop_in, d.loop_state.loop_out) { + let length = out_pt - in_pt; + let new_length = length * 2.0; + let new_beat_count = (d.loop_state.beat_count * 2.0).min(MAX_LOOP_BEATS); + let new_out = in_pt + new_length; + + // Update state + d.loop_state.loop_out = Some(new_out); + d.loop_state.beat_count = new_beat_count; + + (Some(in_pt), Some(new_out), new_beat_count, d.loop_state.active) + } else { + (None, None, 0.0, false) + } + }; + + // Update audio player if loop is defined + if let (Some(loop_in_val), Some(loop_out_val)) = (loop_in, new_loop_out) { + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_loop(loop_in_val, loop_out_val); + } + } + + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLoopStateChanged { + deck: deck_num, + loop_in, + loop_out: new_loop_out, + active: is_active, + beat_count: new_beat_count, + } + )).await; + log::info!("Deck {}: Doubled loop to {} beats", deck, new_beat_count); + } + DjCommand::ImportFolder { path } => { + // Import metadata immediately + self.import_folder(path); + + // Send library update to UI immediately + if let Some(tracks) = self.get_all_tracks_for_ui() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLibraryTracks(tracks) + )).await; + } + + // Send initial analysis progress if there are tracks to analyze + if let Some((track_name, current, total)) = self.analysis_progress() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisProgress { + track_id: 0, // Will be updated during actual analysis + track_name, + current, + total, + progress: 0.0, + } + )).await; + } + } + DjCommand::ReanalyzeTrack { track_id } => { + // Queue track for re-analysis + self.reanalyze_track(track_id); + + // Send immediate progress event + if let Some((track_name, current, total)) = self.analysis_progress() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisProgress { + track_id: track_id.0, + track_name, + current, + total, + progress: 0.0, + } + )).await; + } + } + DjCommand::ToggleSync { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Toggle sync state + let sync_enabled = { + let mut d = self.deck(deck).write(); + d.sync_enabled = !d.sync_enabled; + log::info!( + "Deck {} sync {}", + deck, + if d.sync_enabled { "enabled" } else { "disabled" } + ); + d.sync_enabled + }; + + if sync_enabled { + // Match master's tempo range + if let Some(master) = self.master_deck { + let master_tempo_range = self.deck(master).read().tempo_range; + self.deck(deck).write().tempo_range = master_tempo_range; + + // Send tempo range change event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjTempoRangeChanged { + deck: deck_num, + range: master_tempo_range.to_u8(), + } + )).await; + } + + // Sync to master's BPM + let tempo_range = self.deck(deck).read().tempo_range; + if let Some(engine) = &self.audio_engine { + if engine.sync_to_master(deck, tempo_range) { + // Get new pitch position + let (pitch_percent, adjusted_bpm) = { + let player = engine.deck_player(deck).read(); + let pitch = (player.playback_rate() - 1.0) / tempo_range.as_fraction(); + let bpm = player.effective_bpm().unwrap_or(120.0); + (pitch.clamp(-1.0, 1.0), bpm) + }; + + // Update deck state + { + let mut d = self.deck(deck).write(); + d.pitch_percent = pitch_percent; + d.adjusted_bpm = adjusted_bpm; + } + + // Send pitch changed event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjPitchChanged { + deck: deck_num, + pitch_percent, + tempo_range: tempo_range.to_u8(), + adjusted_bpm, + } + )).await; + + log::info!("Deck {} synced to master BPM: {:.1}", deck, adjusted_bpm); + } else { + log::warn!("Deck {} failed to sync (no master or out of range)", deck); + } + } + } else { + // Disable sync + if let Some(engine) = &self.audio_engine { + engine.disable_sync(deck); + } + } + } + DjCommand::SetPitch { deck, percent } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + let tempo_range = self.deck(deck).read().tempo_range; + + // Apply pitch to this deck + let adjusted_bpm = { + let mut d = self.deck(deck).write(); + d.pitch_percent = percent; + d.update_adjusted_bpm(); + d.adjusted_bpm + }; + + // Update audio engine + if let Some(engine) = &self.audio_engine { + engine.deck_player(deck).write().set_pitch(percent, tempo_range); + } + + // Send pitch changed event for this deck + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjPitchChanged { + deck: deck_num, + pitch_percent: percent, + tempo_range: tempo_range.to_u8(), + adjusted_bpm, + } + )).await; + + // If this deck is master, update all synced decks + if self.master_deck == Some(deck) { + let other_deck = if deck == DeckId::A { DeckId::B } else { DeckId::A }; + let other_deck_num = if deck == DeckId::A { 1 } else { 0 }; + let sync_enabled = self.deck(other_deck).read().sync_enabled; + + if sync_enabled { + let (original_bpm, other_tempo_range) = { + let d = self.deck(other_deck).read(); + (d.original_bpm, d.tempo_range) + }; + + // Calculate required pitch for synced deck to match master BPM + let required_rate = adjusted_bpm / original_bpm; + let range_fraction = other_tempo_range.as_fraction(); + let new_pitch = ((required_rate - 1.0) / range_fraction).clamp(-1.0, 1.0); + + // Apply to synced deck + let other_adjusted_bpm = { + let mut d = self.deck(other_deck).write(); + d.pitch_percent = new_pitch; + d.update_adjusted_bpm(); + d.adjusted_bpm + }; + + // Update audio engine + if let Some(engine) = &self.audio_engine { + engine.deck_player(other_deck).write().set_pitch(new_pitch, other_tempo_range); + } + + // Send pitch changed event for synced deck + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjPitchChanged { + deck: other_deck_num, + pitch_percent: new_pitch, + tempo_range: other_tempo_range.to_u8(), + adjusted_bpm: other_adjusted_bpm, + } + )).await; + + log::debug!("Synced deck {} pitch to {:.2} (BPM: {:.1})", other_deck, new_pitch, other_adjusted_bpm); + } + } + } + DjCommand::NudgeBeatGrid { deck, offset_ms } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Get current beat grid and track info (use deck.original_bpm as authoritative) + let beat_info = { + let d = self.deck(deck).read(); + let duration = d.loaded_track.as_ref().map(|t| t.duration_seconds).unwrap_or(0.0); + let bpm = d.original_bpm; + d.beat_grid.as_ref().map(|bg| { + (bg.track_id, bg.first_beat_offset_ms, bpm, duration) + }) + }; + + if let Some((track_id, current_offset_ms, bpm, duration)) = beat_info { + // Calculate new offset (clamp to valid range) + let new_offset_ms = (current_offset_ms + offset_ms).max(0.0); + let new_offset_seconds = new_offset_ms / 1000.0; + + // Recalculate beat positions from new offset + let beat_interval = 60.0 / bpm; + let mut new_positions = Vec::new(); + let mut pos = new_offset_seconds; + while pos < duration { + new_positions.push(pos); + pos += beat_interval; + } + + // Update deck state + { + let mut d = self.deck(deck).write(); + if let Some(bg) = &mut d.beat_grid { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Update audio engine + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + if let Some(bg) = player.beat_grid_mut() { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Persist to database + if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + let _ = db_guard.update_beat_grid_offset(track_id, new_offset_ms, &new_positions); + } + } + + // Send event to update UI (is_nudge=true so position doesn't change) + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions: new_positions, + first_beat_offset: new_offset_seconds, + bpm, + is_nudge: true, + } + )).await; + + log::info!("Deck {}: Beat grid nudged by {:.1}ms (new offset: {:.1}ms)", deck, offset_ms, new_offset_ms); + } + } + DjCommand::SetDownbeat { deck } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Get current playback position from audio engine + let current_pos = self.audio_engine.as_ref() + .map(|e| e.deck_player(deck).read().position_seconds()) + .unwrap_or(0.0); + + // Get beat grid and track info (use deck.original_bpm as authoritative) + let beat_info = { + let d = self.deck(deck).read(); + let duration = d.loaded_track.as_ref().map(|t| t.duration_seconds).unwrap_or(0.0); + let bpm = d.original_bpm; + d.beat_grid.as_ref().map(|bg| (bg.track_id, bpm, duration)) + }; + + if let Some((track_id, bpm, duration)) = beat_info { + let new_offset_ms = current_pos * 1000.0; + let beat_interval = 60.0 / bpm; + + // Recalculate beat positions starting from current position + let mut new_positions = Vec::new(); + let mut pos = current_pos; + while pos < duration { + new_positions.push(pos); + pos += beat_interval; + } + + // Update deck state + { + let mut d = self.deck(deck).write(); + if let Some(bg) = &mut d.beat_grid { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Update audio engine + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + if let Some(bg) = player.beat_grid_mut() { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Persist to database + if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + let _ = db_guard.update_beat_grid_offset(track_id, new_offset_ms, &new_positions); + } + } + + // Send event to update UI + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions: new_positions, + first_beat_offset: current_pos, + bpm, + is_nudge: true, + } + )).await; + + log::info!("Deck {}: Set downbeat at {:.3}s", deck, current_pos); + } + } + DjCommand::ShiftBeatGrid { deck, beats } => { + let deck_num = if deck == DeckId::A { 0 } else { 1 }; + + // Get beat grid info (use deck.original_bpm as authoritative) + let beat_info = { + let d = self.deck(deck).read(); + let duration = d.loaded_track.as_ref().map(|t| t.duration_seconds).unwrap_or(0.0); + let bpm = d.original_bpm; + d.beat_grid.as_ref().map(|bg| { + (bg.track_id, bg.first_beat_offset_ms, bpm, duration) + }) + }; + + if let Some((track_id, current_offset_ms, bpm, duration)) = beat_info { + let beat_interval_ms = 60000.0 / bpm; + let shift_ms = beats as f64 * beat_interval_ms; + let new_offset_ms = (current_offset_ms + shift_ms).max(0.0); + let new_offset_seconds = new_offset_ms / 1000.0; + + // Recalculate beat positions + let beat_interval = 60.0 / bpm; + let mut new_positions = Vec::new(); + let mut pos = new_offset_seconds; + while pos < duration { + new_positions.push(pos); + pos += beat_interval; + } + + // Update deck state + { + let mut d = self.deck(deck).write(); + if let Some(bg) = &mut d.beat_grid { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Update audio engine + if let Some(engine) = &self.audio_engine { + let mut player = engine.deck_player(deck).write(); + if let Some(bg) = player.beat_grid_mut() { + bg.first_beat_offset_ms = new_offset_ms; + bg.beat_positions = new_positions.clone(); + } + } + + // Persist to database + if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + let _ = db_guard.update_beat_grid_offset(track_id, new_offset_ms, &new_positions); + } + } + + // Send event to update UI + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions: new_positions, + first_beat_offset: new_offset_seconds, + bpm, + is_nudge: true, + } + )).await; + + log::info!("Deck {}: Shifted beat grid by {} beats", deck, beats); + } + } + DjCommand::UpdateTrackBpm { track_id, bpm } => { + // Update BPM in tracks table only (beat grid recalculated on load) + if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + match db_guard.update_track_bpm(track_id, bpm) { + Ok(_) => { + log::info!("Updated track {} BPM to {:.1}", track_id, bpm); + } + Err(e) => { + log::error!("Failed to update track {} BPM: {}", track_id, e); + } + } + } + } + + // Update loaded decks in real-time + for (deck, deck_num) in [(DeckId::A, 0u8), (DeckId::B, 1u8)] { + let beat_info = { + let d = self.deck(deck).read(); + let is_loaded = d.loaded_track.as_ref() + .map(|t| t.id == track_id) + .unwrap_or(false); + if is_loaded { + let duration = d.loaded_track.as_ref() + .map(|t| t.duration_seconds) + .unwrap_or(0.0); + d.beat_grid.as_ref().map(|bg| { + (bg.first_beat_offset_ms, duration) + }) + } else { + None + } + }; + + if let Some((offset_ms, duration)) = beat_info { + let offset_seconds = offset_ms / 1000.0; + let beat_interval = 60.0 / bpm; + + // Recalculate beat positions with new BPM + let mut new_positions = Vec::new(); + let mut pos = offset_seconds; + while pos < duration { + new_positions.push(pos); + pos += beat_interval; + } + + // Update deck state + { + let mut d = self.deck(deck).write(); + d.original_bpm = bpm; + d.adjusted_bpm = bpm; + if let Some(bg) = &mut d.beat_grid { + bg.beat_positions = new_positions.clone(); + } + } + + // Update audio engine (need to reload beat grid with new BPM) + if let Some(engine) = &self.audio_engine { + let beat_grid = self.deck(deck).read().beat_grid.clone(); + if let Some(bg) = beat_grid { + engine.deck_player(deck).write().set_beat_grid(bg, bpm); + } + } + + // Send event to update UI + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjBeatGridLoaded { + deck: deck_num, + beat_positions: new_positions, + first_beat_offset: offset_seconds, + bpm, + is_nudge: true, + } + )).await; + + log::info!("Deck {}: Updated beat grid for new BPM {:.1}", deck, bpm); + } + } + + // Send updated library to UI so it refreshes + if let Some(tracks) = self.get_all_tracks_for_ui() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLibraryTracks(tracks) + )).await; + } + } + other => { + eprintln!("DEBUG: Calling handle_command for {:?}", other); + self.handle_command(other); + } + } + } + } + _ => {} + } + } + + // Process analysis queue during idle time (non-blocking) + _ = rhythm_interval.tick() => { + // Poll for streaming waveform progress (non-blocking) + if let Some(rx) = &mut self.analysis_progress_rx { + while let Ok((samples, progress)) = rx.try_recv() { + if let Some((track_id, track_name)) = &self.current_analysis_track { + // Send streaming waveform progress with special deck value (255 = library analysis) + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjWaveformProgress { + deck: 255, + samples: Arc::new(samples), + frequency_bands: None, + progress, + } + )).await; + + // Also send analysis progress event with intra-track progress for footer display + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisProgress { + track_id: track_id.0, + track_name: track_name.clone(), + current: self.analysis_batch_completed + 1, + total: self.analysis_batch_total, + progress, + } + )).await; + } + } + } + + // Check if background analysis task completed (non-blocking) + let analysis_finished = self.analysis_handle.as_ref().map_or(false, |h| h.is_finished()); + if analysis_finished { + // Take ownership of the handle and await it + let handle = self.analysis_handle.take().unwrap(); + let (track_id, track_name) = self.current_analysis_track.take().unwrap_or((TrackId(0), String::new())); + self.analysis_progress_rx = None; + + match handle.await { + Ok(Some(analysis_result)) => { + // Save results to database + if let Some(db) = &self.database { + if let Ok(db_guard) = db.lock() { + let _ = db_guard.save_waveform(&analysis_result.waveform); + let _ = db_guard.save_beat_grid(&analysis_result.beat_grid); + let _ = db_guard.update_track_bpm(track_id, analysis_result.bpm); + } + } + + self.analysis_batch_completed += 1; + log::info!( + "Analysis complete for {}: BPM={:.1}", + track_name, + analysis_result.bpm + ); + + // Send analysis complete event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisComplete { + track_id: track_id.0, + bpm: Some(analysis_result.bpm), + } + )).await; + } + Ok(None) | Err(_) => { + log::error!("Analysis failed for {}", track_name); + self.analysis_batch_completed += 1; + + // Send analysis complete event with no BPM + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisComplete { + track_id: track_id.0, + bpm: None, + } + )).await; + } + } + + // Check if all analysis is complete + if !self.has_pending_analysis() && !self.is_analysis_running() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::StatusClear + )).await; + + // Send updated library with BPM values + if let Some(tracks) = self.get_all_tracks_for_ui() { + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjLibraryTracks(tracks) + )).await; + } + } + } + + // Start next analysis if none is running and queue is not empty (non-blocking) + if !self.is_analysis_running() && self.has_pending_analysis() { + if let Some((track_id, track_name)) = self.start_next_analysis() { + // Send progress event + let _ = tx.send(ModuleMessage::Event( + ModuleEvent::DjAnalysisProgress { + track_id: track_id.0, + track_name, + current: self.analysis_batch_completed + 1, + total: self.analysis_batch_total, + progress: 0.0, + } + )).await; + } + } + // Collect events to send (without holding locks across await) + let mut events_to_send = Vec::new(); + + if let Some(engine) = &self.audio_engine { + // Update sync corrections for continuous beat lock + engine.update_sync_corrections(); + + // Get master deck rhythm sync info + if let Some(master) = engine.master_deck() { + let player = engine.deck_player(master).read(); + if player.state() == PlayerState::Playing { + if let (Some(bpm), Some(beat_phase), Some(bar_phase), Some(phrase_phase)) = ( + player.effective_bpm(), + player.beat_phase(), + player.bar_phase(), + player.phrase_phase(), + ) { + events_to_send.push(ModuleEvent::DjRhythmSync { + bpm, + beat_phase, + bar_phase, + phrase_phase, + }); + } + } + } + + // Send position updates and check for beat triggers on Deck A + { + // Check for quantized play trigger first (needs write lock) + let started_from_quantized = { + let mut player = engine.deck_player(DeckId::A).write(); + player.check_quantized_play() + }; + + let player = engine.deck_player(DeckId::A).read(); + let is_playing = player.state() == PlayerState::Playing; + let is_waiting = player.is_waiting_for_quantized_start(); + let position = if is_waiting { + player.virtual_position() + } else { + player.position_seconds() + }; + let adjusted_bpm = self.deck(DeckId::A).read().adjusted_bpm; + + // Send position updates when playing or waiting for quantized start + if is_playing || is_waiting || started_from_quantized { + events_to_send.push(ModuleEvent::DjDeckStateChanged { + deck: 0, + is_playing: is_playing || started_from_quantized, + position_seconds: position, + bpm: Some(adjusted_bpm), + }); + } + + // Check for beat triggers + if is_playing { + if let Some(beat_num) = player.current_beat_number() { + if last_beat_a.map_or(true, |last| beat_num > last) { + let is_downbeat = player.bar_phase().map_or(false, |phase| phase < 0.25); + events_to_send.push(ModuleEvent::DjBeat { + deck: 0, + beat_number: beat_num, + is_downbeat, + }); + last_beat_a = Some(beat_num); + } + } + } + } + + // Send position updates and check for beat triggers on Deck B + { + // Check for quantized play trigger first (needs write lock) + let started_from_quantized = { + let mut player = engine.deck_player(DeckId::B).write(); + player.check_quantized_play() + }; + + let player = engine.deck_player(DeckId::B).read(); + let is_playing = player.state() == PlayerState::Playing; + let is_waiting = player.is_waiting_for_quantized_start(); + let position = if is_waiting { + player.virtual_position() + } else { + player.position_seconds() + }; + let adjusted_bpm = self.deck(DeckId::B).read().adjusted_bpm; + + // Send position updates when playing or waiting for quantized start + if is_playing || is_waiting || started_from_quantized { + events_to_send.push(ModuleEvent::DjDeckStateChanged { + deck: 1, + is_playing: is_playing || started_from_quantized, + position_seconds: position, + bpm: Some(adjusted_bpm), + }); + } + + // Check for beat triggers + if is_playing { + if let Some(beat_num) = player.current_beat_number() { + if last_beat_b.map_or(true, |last| beat_num > last) { + let is_downbeat = player.bar_phase().map_or(false, |phase| phase < 0.25); + events_to_send.push(ModuleEvent::DjBeat { + deck: 1, + beat_number: beat_num, + is_downbeat, + }); + last_beat_b = Some(beat_num); + } + } + } + } + } + + // Now send events (locks are dropped, safe to await) + for event in events_to_send { + let _ = tx.send(ModuleMessage::Event(event)).await; + } + } + } + } + + // Send status before shutdown + let _ = tx + .send(ModuleMessage::Status("DJ module stopped".to_string())) + .await; + + Ok(()) + } + + async fn shutdown(&mut self) -> Result<(), Box> { + log::info!("Shutting down DJ module"); + + // Stop audio engine + if let Some(engine) = &mut self.audio_engine { + engine.stop(); + log::info!("Audio engine stopped"); + } + + // Close database (implicitly done when dropped) + self.database = None; + + log::info!("DJ module shutdown complete"); + Ok(()) + } + + fn status(&self) -> HashMap { + let deck_a = self.deck_a.read(); + let deck_b = self.deck_b.read(); + + let mut status = HashMap::new(); + status.insert("deck_a_state".to_string(), format!("{:?}", deck_a.state)); + status.insert("deck_b_state".to_string(), format!("{:?}", deck_b.state)); + status.insert( + "deck_a_bpm".to_string(), + format!("{:.2}", deck_a.adjusted_bpm), + ); + status.insert( + "deck_b_bpm".to_string(), + format!("{:.2}", deck_b.adjusted_bpm), + ); + status.insert( + "master".to_string(), + self.master_deck + .map(|d| format!("{}", d)) + .unwrap_or_else(|| "none".to_string()), + ); + status.insert( + "library_path".to_string(), + self.library_path.display().to_string(), + ); + status + } +} diff --git a/crates/dj/src/module/time_stretcher.rs b/crates/dj/src/module/time_stretcher.rs new file mode 100644 index 0000000..d2b7e80 --- /dev/null +++ b/crates/dj/src/module/time_stretcher.rs @@ -0,0 +1,199 @@ +//! Real-time time stretching for Master Tempo (key lock) functionality. +//! +//! Uses the SoundTouch library (WSOLA algorithm) to change tempo without +//! affecting pitch. This enables DJ-style Master Tempo functionality. + +use std::collections::VecDeque; + +use soundtouch::SoundTouch; + +/// Wrapper around SoundTouch that implements Send + Sync. +struct SoundTouchWrapper(SoundTouch); + +unsafe impl Send for SoundTouchWrapper {} +unsafe impl Sync for SoundTouchWrapper {} + +impl SoundTouchWrapper { + fn new() -> Self { + Self(SoundTouch::new()) + } +} + +impl std::ops::Deref for SoundTouchWrapper { + type Target = SoundTouch; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for SoundTouchWrapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Real-time time stretcher for audio playback. +pub struct TimeStretcher { + processor: SoundTouchWrapper, + sample_rate: u32, + tempo: f64, + /// Output buffer for processed stereo samples. + output_buffer: VecDeque<(f32, f32)>, + /// Temp buffer for receiving from SoundTouch. + receive_buffer: Vec, +} + +impl TimeStretcher { + /// Create a new time stretcher. + pub fn new(sample_rate: u32, channels: u32) -> Self { + let mut processor = SoundTouchWrapper::new(); + + processor.set_sample_rate(sample_rate); + processor.set_channels(channels); + + log::info!( + "TimeStretcher initialized: {} Hz, {} channels", + sample_rate, + channels + ); + + Self { + processor, + sample_rate, + tempo: 1.0, + output_buffer: VecDeque::with_capacity(8192), + receive_buffer: vec![0.0f32; 4096], + } + } + + /// Set the tempo ratio (1.0 = normal speed). + pub fn set_tempo(&mut self, ratio: f64) { + let ratio = ratio.clamp(0.01, 2.0); + if (ratio - self.tempo).abs() > 0.001 { + self.tempo = ratio; + self.processor.set_tempo(ratio); + log::debug!("TimeStretcher: tempo set to {:.4}", ratio); + } + } + + /// Get the current tempo ratio. + pub fn tempo(&self) -> f64 { + self.tempo + } + + /// Push a stereo sample pair and immediately try to get output. + pub fn push_sample(&mut self, left: f32, right: f32) { + // Feed one stereo frame to SoundTouch + let input = [left, right]; + self.processor.put_samples(&input, 1); + + // Try to receive any available output + self.receive_samples(); + } + + /// Pop a processed stereo sample pair. + pub fn pop_sample(&mut self) -> Option<(f32, f32)> { + // Try to get more samples if buffer is low + if self.output_buffer.len() < 100 { + self.receive_samples(); + } + self.output_buffer.pop_front() + } + + /// Receive available samples from SoundTouch. + fn receive_samples(&mut self) { + loop { + let received = self + .processor + .receive_samples(&mut self.receive_buffer, 1024); + if received == 0 { + break; + } + for i in 0..received { + let left = self.receive_buffer[i * 2]; + let right = self.receive_buffer[i * 2 + 1]; + self.output_buffer.push_back((left, right)); + } + } + } + + /// Check if there are samples available. + pub fn has_output(&self) -> bool { + !self.output_buffer.is_empty() + } + + /// Get the number of buffered output samples. + pub fn output_len(&self) -> usize { + self.output_buffer.len() + } + + /// Flush remaining samples. + pub fn flush(&mut self) { + self.processor.flush(); + self.receive_samples(); + } + + /// Reset to initial state. + pub fn reset(&mut self) { + self.processor.clear(); + self.output_buffer.clear(); + } + + /// Get latency in samples. + pub fn latency_samples(&self) -> usize { + self.processor.num_unprocessed_samples() as usize + } + + /// Get latency in seconds. + pub fn latency_seconds(&self) -> f64 { + self.latency_samples() as f64 / self.sample_rate as f64 + } +} + +impl Default for TimeStretcher { + fn default() -> Self { + Self::new(44100, 2) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_time_stretcher_creation() { + let stretcher = TimeStretcher::new(44100, 2); + assert_eq!(stretcher.tempo(), 1.0); + } + + #[test] + fn test_tempo_setting() { + let mut stretcher = TimeStretcher::new(44100, 2); + + stretcher.set_tempo(1.1); + assert!((stretcher.tempo() - 1.1).abs() < 0.001); + + stretcher.set_tempo(0.9); + assert!((stretcher.tempo() - 0.9).abs() < 0.001); + } + + #[test] + fn test_sample_processing() { + let mut stretcher = TimeStretcher::new(44100, 2); + + // Push samples + for i in 0..2000 { + let sample = (i as f32 / 1000.0).sin(); + stretcher.push_sample(sample, sample); + } + + stretcher.flush(); + + // Should have output + let mut count = 0; + while stretcher.pop_sample().is_some() { + count += 1; + } + assert!(count > 0); + } +} diff --git a/crates/dj/tests/bpm_accuracy_test.rs b/crates/dj/tests/bpm_accuracy_test.rs new file mode 100644 index 0000000..bc8a428 --- /dev/null +++ b/crates/dj/tests/bpm_accuracy_test.rs @@ -0,0 +1,503 @@ +//! BPM accuracy regression tests using ground truth datasets. +//! +//! These tests validate that changes to BPM detection algorithms don't +//! degrade accuracy. They use the GiantSteps Tempo Dataset which contains +//! 664 electronic dance music tracks with crowdsourced BPM annotations. +//! +//! ## Setup +//! +//! To run these tests, first download the test audio: +//! ```bash +//! ./scripts/download_test_audio.sh +//! ``` +//! +//! ## Running Tests +//! +//! ```bash +//! # Run accuracy tests +//! cargo test --package halo-dj --features accuracy-tests -- --nocapture +//! +//! # Run synthetic tests only (no external audio needed) +//! cargo test --package halo-dj test_synthetic +//! ``` +//! +//! ## Accuracy Metrics +//! +//! - **Accuracy 1 (Strict)**: BPM within ±2% of ground truth +//! - **Accuracy 2 (Octave-tolerant)**: BPM or 2x/0.5x within ±2% +//! - **MIREX Accuracy**: BPM within ±8% (academic standard) + +use std::f32::consts::PI; +use std::fs; +use std::path::PathBuf; + +use halo_dj::library::{analyze_file, AnalysisConfig}; + +/// Ground truth data structure matching the JSON schema. +#[derive(Debug, serde::Deserialize)] +struct GroundTruth { + version: u32, + dataset: String, + #[allow(dead_code)] + description: String, + #[allow(dead_code)] + source: String, + tracks: Vec, +} + +/// Individual track annotation. +#[derive(Debug, serde::Deserialize)] +struct TrackAnnotation { + filename: String, + expected_bpm: f64, + tolerance_percent: f64, + #[allow(dead_code)] + genre: String, + #[allow(dead_code)] + notes: String, +} + +/// Accuracy statistics for reporting. +#[derive(Debug, Default)] +struct AccuracyStats { + total: usize, + accuracy1_correct: usize, // Exact match within tolerance + accuracy2_correct: usize, // Octave-tolerant match + mirex_correct: usize, // Within 8% + failed_tracks: Vec<(String, f64, f64)>, // (filename, expected, detected) +} + +impl AccuracyStats { + fn accuracy1_percent(&self) -> f64 { + if self.total == 0 { + 0.0 + } else { + 100.0 * self.accuracy1_correct as f64 / self.total as f64 + } + } + + fn accuracy2_percent(&self) -> f64 { + if self.total == 0 { + 0.0 + } else { + 100.0 * self.accuracy2_correct as f64 / self.total as f64 + } + } + + fn mirex_percent(&self) -> f64 { + if self.total == 0 { + 0.0 + } else { + 100.0 * self.mirex_correct as f64 / self.total as f64 + } + } +} + +/// Check if detected BPM matches expected within tolerance. +fn is_bpm_match(expected: f64, detected: f64, tolerance_percent: f64) -> bool { + let tolerance = expected * tolerance_percent / 100.0; + (detected - expected).abs() <= tolerance +} + +/// Check if detected BPM matches expected or its octave/metrical multiples. +fn is_octave_tolerant_match(expected: f64, detected: f64, tolerance_percent: f64) -> bool { + // Common metrical relationships to check + let multipliers = [ + 1.0, // Exact match + 2.0, // Double tempo + 0.5, // Half tempo + 3.0, // Triple tempo + 1.0 / 3.0, // Third tempo + 1.5, // Dotted tempo (3/2) + 2.0 / 3.0, // Two-thirds tempo (common in EDM) + 4.0, // Quadruple tempo + 0.25, // Quarter tempo + ]; + + for &mult in &multipliers { + if is_bpm_match(expected * mult, detected, tolerance_percent) { + return true; + } + } + false +} + +/// Generate a synthetic click track at a specific BPM for deterministic testing. +/// +/// Creates a sine wave "click" at each beat position. +fn generate_click_track(bpm: f64, duration_secs: f64, sample_rate: u32) -> Vec { + let total_samples = (duration_secs * sample_rate as f64) as usize; + let beat_interval_samples = (60.0 / bpm * sample_rate as f64) as usize; + let click_duration_samples = (0.02 * sample_rate as f64) as usize; // 20ms click + + let mut samples = vec![0.0f32; total_samples]; + + // Generate click at each beat + let mut beat_pos = 0; + while beat_pos < total_samples { + for i in 0..click_duration_samples.min(total_samples - beat_pos) { + // Sine wave click with envelope + let t = i as f32 / sample_rate as f32; + let envelope = 1.0 - (i as f32 / click_duration_samples as f32); + let click_freq = 1000.0; // 1kHz click + samples[beat_pos + i] = (2.0 * PI * click_freq * t).sin() * envelope * 0.8; + } + beat_pos += beat_interval_samples; + } + + samples +} + +/// Generate a more realistic four-on-the-floor pattern for testing. +/// Includes kick, snare, and hi-hat for better beat detection. +fn generate_kick_pattern(bpm: f64, duration_secs: f64, sample_rate: u32) -> Vec { + let total_samples = (duration_secs * sample_rate as f64) as usize; + let beat_interval_samples = (60.0 / bpm * sample_rate as f64) as usize; + let eighth_interval = beat_interval_samples / 2; + + let mut samples = vec![0.0f32; total_samples]; + + // Generate a full bar pattern (4 beats) + let mut pos = 0usize; + let mut beat_in_bar = 0; + + while pos < total_samples { + // Kick on every beat (four-on-the-floor) + add_kick(&mut samples, pos, sample_rate); + + // Snare on beats 2 and 4 + if beat_in_bar == 1 || beat_in_bar == 3 { + add_snare(&mut samples, pos, sample_rate); + } + + // Closed hi-hat on every eighth note + add_hihat(&mut samples, pos, sample_rate, false); + if pos + eighth_interval < total_samples { + add_hihat(&mut samples, pos + eighth_interval, sample_rate, false); + } + + // Open hi-hat on the "and" of beat 4 + if beat_in_bar == 3 && pos + eighth_interval < total_samples { + add_hihat(&mut samples, pos + eighth_interval, sample_rate, true); + } + + pos += beat_interval_samples; + beat_in_bar = (beat_in_bar + 1) % 4; + } + + // Normalize to prevent clipping + let max_val = samples.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + if max_val > 0.0 { + for s in &mut samples { + *s /= max_val * 1.1; // Leave some headroom + } + } + + samples +} + +/// Add a kick drum sound at the given position. +fn add_kick(samples: &mut [f32], pos: usize, sample_rate: u32) { + let duration = (0.15 * sample_rate as f64) as usize; + for i in 0..duration.min(samples.len().saturating_sub(pos)) { + let t = i as f32 / sample_rate as f32; + // Frequency sweep from 150Hz to 40Hz + let freq = 150.0 * (-t * 25.0).exp() + 40.0; + let envelope = (-t * 15.0).exp(); + // Add some click for attack + let click = if i < 50 { + (1.0 - i as f32 / 50.0) * 0.3 + } else { + 0.0 + }; + samples[pos + i] += ((2.0 * PI * freq * t).sin() * envelope + click) * 0.8; + } +} + +/// Add a snare drum sound at the given position. +fn add_snare(samples: &mut [f32], pos: usize, sample_rate: u32) { + let duration = (0.12 * sample_rate as f64) as usize; + for i in 0..duration.min(samples.len().saturating_sub(pos)) { + let t = i as f32 / sample_rate as f32; + // Body tone at ~180Hz + let body = (2.0 * PI * 180.0 * t).sin() * (-t * 20.0).exp(); + // Noise for snare wires (simple random-ish noise using sin) + let noise = (t * 12345.6789).sin() * (-t * 30.0).exp(); + samples[pos + i] += (body * 0.3 + noise * 0.4) * 0.5; + } +} + +/// Add a hi-hat sound at the given position. +fn add_hihat(samples: &mut [f32], pos: usize, sample_rate: u32, open: bool) { + let duration = if open { + (0.15 * sample_rate as f64) as usize + } else { + (0.05 * sample_rate as f64) as usize + }; + let decay = if open { 10.0 } else { 40.0 }; + + for i in 0..duration.min(samples.len().saturating_sub(pos)) { + let t = i as f32 / sample_rate as f32; + // High frequency noise-like sound + let noise = + (t * 54321.0).sin() * 0.5 + (t * 98765.0).sin() * 0.3 + (t * 23456.0).sin() * 0.2; + let envelope = (-t * decay).exp(); + samples[pos + i] += noise * envelope * 0.15; + } +} + +// ============================================================================ +// Synthetic Audio Tests (always run, no external dependencies) +// ============================================================================ + +#[test] +fn test_synthetic_120bpm_click() { + let samples = generate_click_track(120.0, 30.0, 44100); + + // Write to temp file + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = temp_dir.path().join("click_120bpm.wav"); + write_wav(&temp_path, &samples, 44100); + + // Analyze + let config = AnalysisConfig::default(); + let result = analyze_file(&temp_path, halo_dj::library::TrackId(1), &config).unwrap(); + + // Check BPM (allow octave match since click tracks can be ambiguous) + assert!( + is_octave_tolerant_match(120.0, result.bpm, 2.0), + "Expected ~120 BPM (or octave), got {:.2}", + result.bpm + ); +} + +#[test] +fn test_synthetic_128bpm_kick() { + let samples = generate_kick_pattern(128.0, 30.0, 44100); + + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = temp_dir.path().join("kick_128bpm.wav"); + write_wav(&temp_path, &samples, 44100); + + let config = AnalysisConfig::default(); + let result = analyze_file(&temp_path, halo_dj::library::TrackId(1), &config).unwrap(); + + assert!( + is_octave_tolerant_match(128.0, result.bpm, 3.0), + "Expected ~128 BPM (or octave), got {:.2}", + result.bpm + ); +} + +#[test] +fn test_synthetic_various_tempos() { + let test_tempos = [80.0, 100.0, 120.0, 128.0, 140.0, 160.0, 175.0]; + + for &expected_bpm in &test_tempos { + let samples = generate_kick_pattern(expected_bpm, 30.0, 44100); + + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = temp_dir + .path() + .join(format!("kick_{:.0}bpm.wav", expected_bpm)); + write_wav(&temp_path, &samples, 44100); + + let config = AnalysisConfig::default(); + let result = analyze_file(&temp_path, halo_dj::library::TrackId(1), &config).unwrap(); + + assert!( + is_octave_tolerant_match(expected_bpm, result.bpm, 3.0), + "For {:.0} BPM: expected match (or octave), got {:.2}", + expected_bpm, + result.bpm + ); + } +} + +/// Write samples to a WAV file for testing. +fn write_wav(path: &PathBuf, samples: &[f32], sample_rate: u32) { + use std::io::Write; + + let num_samples = samples.len() as u32; + let byte_rate = sample_rate * 2; // 16-bit mono + let data_size = num_samples * 2; + let file_size = 36 + data_size; + + let mut file = fs::File::create(path).unwrap(); + + // RIFF header + file.write_all(b"RIFF").unwrap(); + file.write_all(&file_size.to_le_bytes()).unwrap(); + file.write_all(b"WAVE").unwrap(); + + // fmt chunk + file.write_all(b"fmt ").unwrap(); + file.write_all(&16u32.to_le_bytes()).unwrap(); // Chunk size + file.write_all(&1u16.to_le_bytes()).unwrap(); // PCM format + file.write_all(&1u16.to_le_bytes()).unwrap(); // Mono + file.write_all(&sample_rate.to_le_bytes()).unwrap(); + file.write_all(&byte_rate.to_le_bytes()).unwrap(); + file.write_all(&2u16.to_le_bytes()).unwrap(); // Block align + file.write_all(&16u16.to_le_bytes()).unwrap(); // Bits per sample + + // data chunk + file.write_all(b"data").unwrap(); + file.write_all(&data_size.to_le_bytes()).unwrap(); + + // Write samples as 16-bit PCM + for &sample in samples { + let sample_i16 = (sample.clamp(-1.0, 1.0) * 32767.0) as i16; + file.write_all(&sample_i16.to_le_bytes()).unwrap(); + } +} + +// ============================================================================ +// GiantSteps Dataset Tests (feature-gated, requires downloaded audio) +// ============================================================================ + +#[cfg(feature = "accuracy-tests")] +mod accuracy_tests { + use super::*; + + fn get_fixtures_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + } + + fn load_ground_truth() -> GroundTruth { + let path = get_fixtures_path().join("ground_truth.json"); + let content = fs::read_to_string(&path).expect("Failed to read ground_truth.json"); + serde_json::from_str(&content).expect("Failed to parse ground_truth.json") + } + + #[test] + fn test_giantsteps_accuracy() { + let ground_truth = load_ground_truth(); + let audio_dir = get_fixtures_path().join("audio"); + + if ground_truth.tracks.is_empty() { + println!("No tracks in ground_truth.json."); + println!("Run ./scripts/download_test_audio.sh to download the GiantSteps dataset."); + return; + } + + let config = AnalysisConfig::default(); + let mut stats = AccuracyStats::default(); + + println!("\n=== BPM Accuracy Test: {} ===\n", ground_truth.dataset); + + for (i, track) in ground_truth.tracks.iter().enumerate() { + let audio_path = audio_dir.join(&track.filename); + + if !audio_path.exists() { + println!( + "[{}/{}] SKIP: {} (file not found)", + i + 1, + ground_truth.tracks.len(), + track.filename + ); + continue; + } + + stats.total += 1; + + // Analyze track + let result = + match analyze_file(&audio_path, halo_dj::library::TrackId(i as i64), &config) { + Ok(r) => r, + Err(e) => { + println!( + "[{}/{}] ERROR: {} - {}", + i + 1, + ground_truth.tracks.len(), + track.filename, + e + ); + continue; + } + }; + + let detected_bpm = result.bpm; + let expected_bpm = track.expected_bpm; + + // Check accuracy metrics + let acc1 = is_bpm_match(expected_bpm, detected_bpm, track.tolerance_percent); + let acc2 = + is_octave_tolerant_match(expected_bpm, detected_bpm, track.tolerance_percent); + let mirex = is_bpm_match(expected_bpm, detected_bpm, 8.0) + || is_octave_tolerant_match(expected_bpm, detected_bpm, 8.0); + + if acc1 { + stats.accuracy1_correct += 1; + } + if acc2 { + stats.accuracy2_correct += 1; + } + if mirex { + stats.mirex_correct += 1; + } + + let status = if acc1 { + "OK" + } else if acc2 { + "OCTAVE" + } else { + stats + .failed_tracks + .push((track.filename.clone(), expected_bpm, detected_bpm)); + "FAIL" + }; + + println!( + "[{}/{}] {}: {} - expected {:.2}, detected {:.2}", + i + 1, + ground_truth.tracks.len(), + status, + track.filename, + expected_bpm, + detected_bpm + ); + } + + // Print summary + println!("\n=== Accuracy Summary ===\n"); + println!("Total tracks tested: {}", stats.total); + println!( + "Accuracy 1 (±2%): {:.1}% ({}/{})", + stats.accuracy1_percent(), + stats.accuracy1_correct, + stats.total + ); + println!( + "Accuracy 2 (octave ±2%): {:.1}% ({}/{})", + stats.accuracy2_percent(), + stats.accuracy2_correct, + stats.total + ); + println!( + "MIREX (±8%): {:.1}% ({}/{})", + stats.mirex_percent(), + stats.mirex_correct, + stats.total + ); + + if !stats.failed_tracks.is_empty() { + println!("\n=== Failed Tracks ===\n"); + for (filename, expected, detected) in &stats.failed_tracks { + let ratio = detected / expected; + println!( + "{}: expected {:.2}, detected {:.2} (ratio: {:.2}x)", + filename, expected, detected, ratio + ); + } + } + + // Assert minimum accuracy threshold + assert!( + stats.accuracy2_percent() >= 85.0, + "Accuracy 2 (octave-tolerant) should be at least 85%, got {:.1}%", + stats.accuracy2_percent() + ); + } +} diff --git a/crates/dj/tests/fixtures/.gitignore b/crates/dj/tests/fixtures/.gitignore new file mode 100644 index 0000000..6cbd4a8 --- /dev/null +++ b/crates/dj/tests/fixtures/.gitignore @@ -0,0 +1,9 @@ +# Ignore downloaded audio files (too large and potentially copyrighted) +audio/*.mp3 +audio/*.wav +audio/*.flac +audio/*.m4a +audio/*.ogg + +# Keep the audio directory structure +!audio/.gitkeep diff --git a/crates/dj/tests/fixtures/README.md b/crates/dj/tests/fixtures/README.md new file mode 100644 index 0000000..5bae98b --- /dev/null +++ b/crates/dj/tests/fixtures/README.md @@ -0,0 +1,53 @@ +# BPM Accuracy Test Fixtures + +This directory contains ground truth data for BPM detection accuracy testing. + +## Setup + +To run the BPM accuracy tests, you need to download the GiantSteps Tempo Dataset: + +```bash +# From the repository root +./scripts/download_test_audio.sh +``` + +This will: +1. Clone the GiantSteps dataset into `giantsteps-tempo-dataset/` +2. Download ~664 electronic dance music track previews (~1GB) into `audio/` +3. Generate `ground_truth.json` from the dataset annotations + +All downloaded files are ignored by `.gitignore` and won't be committed to the repository. + +## Running Tests + +```bash +# Run accuracy tests (requires downloaded audio) +cargo test --package halo-dj --features accuracy-tests + +# Run with verbose output +cargo test --package halo-dj --features accuracy-tests -- --nocapture +``` + +## Files + +- `ground_truth.json` - Expected BPM values for each track from crowdsourced annotations +- `audio/` - Downloaded audio files (not committed to git) + +## Dataset Source + +The test audio comes from the [GiantSteps Tempo Dataset](https://github.com/GiantSteps/giantsteps-tempo-dataset): + +> P. Knees et al.: "Two data sets for tempo estimation and key detection in +> electronic dance music annotated from user corrections" (ISMIR 2015) + +Ground truth BPM values were manually corrected by the DJ community on Beatport forums. + +## Accuracy Metrics + +The tests report three accuracy levels: + +1. **Accuracy 1 (Strict)**: Detected BPM within ±2% of ground truth +2. **Accuracy 2 (Octave-tolerant)**: Detected BPM or octave multiple (2x, 0.5x) within ±2% +3. **MIREX Accuracy**: Detected BPM within ±8% (academic standard) + +For DJ applications, Accuracy 2 ≥90% is the target. diff --git a/crates/dj/tests/fixtures/audio/.gitkeep b/crates/dj/tests/fixtures/audio/.gitkeep new file mode 100644 index 0000000..195508d --- /dev/null +++ b/crates/dj/tests/fixtures/audio/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the audio directory is tracked by git +# Actual audio files are ignored - see ../.gitignore diff --git a/crates/dj/tests/fixtures/ground_truth.json b/crates/dj/tests/fixtures/ground_truth.json new file mode 100644 index 0000000..250c286 --- /dev/null +++ b/crates/dj/tests/fixtures/ground_truth.json @@ -0,0 +1,4656 @@ +{ + "version": 1, + "dataset": "giantsteps-tempo", + "description": "BPM ground truth from GiantSteps Tempo Dataset (crowdsourced corrections)", + "source": "https://github.com/GiantSteps/giantsteps-tempo-dataset", + "tracks": [ + { + "filename": "1030011.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "1068430.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1084996.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "1092771.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1114156.LOFI.mp3", + "expected_bpm": 86.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "1118326.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "1120171.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1171800.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1174239.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "1177875.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "1183908.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1198571.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "122772.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1234668.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1234669.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1234745.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1234750.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1240669.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1240672.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1317507.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1327052.LOFI.mp3", + "expected_bpm": 0.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "1329955.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "1380256.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1418652.LOFI.mp3", + "expected_bpm": 66.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1424458.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1461087.LOFI.mp3", + "expected_bpm": 90.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1469706.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "1479462.LOFI.mp3", + "expected_bpm": 99.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1514866.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1548088.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1556416.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "1560139.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1560729.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "1562235.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "1569136.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "1623443.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "1626348.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1676961.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1698047.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "172384.LOFI.mp3", + "expected_bpm": 84.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "1728723.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1735621.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1743969.LOFI.mp3", + "expected_bpm": 79.0, + "tolerance_percent": 2.0, + "genre": "pop-rock", + "notes": "" + }, + { + "filename": "1747518.LOFI.mp3", + "expected_bpm": 71.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "1753073.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "1765409.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "1791554.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1816200.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1817444.LOFI.mp3", + "expected_bpm": 172.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1825982.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "1839656.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1842615.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "1851619.LOFI.mp3", + "expected_bpm": 99.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "1855660.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1874244.LOFI.mp3", + "expected_bpm": 71.0, + "tolerance_percent": 2.0, + "genre": "reggae-dub", + "notes": "" + }, + { + "filename": "1885798.LOFI.mp3", + "expected_bpm": 101.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "1889739.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "1889844.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1896782.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1905591.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1905592.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1918704.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1921248.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "1929611.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "1943706.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1950701.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "1955621.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "1960598.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "1968659.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "1973680.LOFI.mp3", + "expected_bpm": 80.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "1974485.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "1982430.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "1982431.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "2013128.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "2022116.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "2039114.LOFI.mp3", + "expected_bpm": 119.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "2048806.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2061919.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "2071203.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2083393.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "2083969.LOFI.mp3", + "expected_bpm": 69.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "2088277.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "2088281.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "210560.LOFI.mp3", + "expected_bpm": 78.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "220883.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2422602.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2432724.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "2442269.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2673896.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "2676246.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "2676506.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "2677224.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2702022.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "2703359.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "2704868.LOFI.mp3", + "expected_bpm": 133.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "2706792.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "2720752.LOFI.mp3", + "expected_bpm": 112.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "2725284.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "2725286.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "2725352.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2725358.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2725361.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2726350.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2726353.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2726355.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2734649.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "2734862.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "2741734.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "2745205.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "2751688.LOFI.mp3", + "expected_bpm": 120.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "2757093.LOFI.mp3", + "expected_bpm": 81.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "2759853.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "278893.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "28952.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "2992360.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3013673.LOFI.mp3", + "expected_bpm": 156.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "3013772.LOFI.mp3", + "expected_bpm": 83.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3016341.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3023605.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3040535.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3041381.LOFI.mp3", + "expected_bpm": 0.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3041383.LOFI.mp3", + "expected_bpm": 0.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3058700.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3059880.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3062661.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3069960.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3079792.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3088145.LOFI.mp3", + "expected_bpm": 64.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3089629.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3091433.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3091814.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3097674.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3101046.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "310291.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3109241.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3116297.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3124242.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3125254.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3128068.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3128701.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3130573.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3134837.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3151015.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "pop-rock", + "notes": "" + }, + { + "filename": "3158774.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3165747.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3167057.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3169408.LOFI.mp3", + "expected_bpm": 112.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3173482.LOFI.mp3", + "expected_bpm": 83.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3173803.LOFI.mp3", + "expected_bpm": 188.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "3181214.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3188462.LOFI.mp3", + "expected_bpm": 115.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3189712.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3190021.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3190084.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3194652.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3202338.LOFI.mp3", + "expected_bpm": 80.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3208076.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3209004.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3209061.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3211234.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3213176.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3218565.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "3218804.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3220340.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3226171.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3226172.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3226180.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3230477.LOFI.mp3", + "expected_bpm": 101.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3230949.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3241615.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3244386.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3247762.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "3261686.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3261883.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3264338.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3267829.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3269490.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3269670.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3281282.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3289180.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3291017.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3297802.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3298908.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3303241.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3304445.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3312045.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3313129.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3317417.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3333492.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "333579.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3336126.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3336604.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3343760.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3344148.LOFI.mp3", + "expected_bpm": 109.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "3349486.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3368053.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3368054.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3368055.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3370500.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3372883.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3377892.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3383105.LOFI.mp3", + "expected_bpm": 81.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3387888.LOFI.mp3", + "expected_bpm": 88.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3391054.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3398611.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3403794.LOFI.mp3", + "expected_bpm": 192.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "3405537.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3409480.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3412362.LOFI.mp3", + "expected_bpm": 119.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3414605.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "3414746.LOFI.mp3", + "expected_bpm": 86.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "3419452.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3428474.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3433621.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3435022.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3439626.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3443201.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "344470.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3453642.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3460093.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3460094.LOFI.mp3", + "expected_bpm": 171.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3467867.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3471827.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3475669.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3475672.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3480108.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "3482508.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3482510.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3482733.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3484119.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3485917.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3486206.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3492833.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3493047.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3509304.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3511308.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "3512227.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3519546.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3520036.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3530058.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3534448.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3535112.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3535520.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "3558940.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3564559.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3565815.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3566606.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3574815.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3577628.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3577631.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3593643.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3596904.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3605780.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3612407.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3618176.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3621649.LOFI.mp3", + "expected_bpm": 178.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3629920.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3630279.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3630280.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3638589.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "3640677.LOFI.mp3", + "expected_bpm": 115.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3642438.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3649527.LOFI.mp3", + "expected_bpm": 90.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3649559.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3650152.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3651620.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3655121.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3656301.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3658743.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "365981.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3660450.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3661855.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3665864.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3674961.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3678548.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3682931.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "368533.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3692859.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3696445.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3703893.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3706845.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3711752.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "3721308.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3724279.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3725208.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3730279.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "3733281.LOFI.mp3", + "expected_bpm": 70.0, + "tolerance_percent": 2.0, + "genre": "reggae-dub", + "notes": "" + }, + { + "filename": "3741346.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3742708.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3743957.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3746398.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3752371.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "3754674.LOFI.mp3", + "expected_bpm": 71.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3766995.LOFI.mp3", + "expected_bpm": 95.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "3783220.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "3787878.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3789981.LOFI.mp3", + "expected_bpm": 86.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3790342.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3792954.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "3801596.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "3807114.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3809763.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3813277.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "3818797.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3819089.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3824612.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3828895.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3829043.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3835017.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3853293.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3853477.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3863423.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3863498.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "hip-hop", + "notes": "" + }, + { + "filename": "3865706.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3871968.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3873406.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "3873575.LOFI.mp3", + "expected_bpm": 88.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3875632.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3875836.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3877057.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3879813.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "3883083.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3883902.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3886051.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "3886124.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3889689.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "3892740.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "pop-rock", + "notes": "" + }, + { + "filename": "3902915.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "3907042.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3914630.LOFI.mp3", + "expected_bpm": 86.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3920460.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "3928924.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3942754.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "3958395.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "funk-r-and-b", + "notes": "" + }, + { + "filename": "3961040.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3961797.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "3962200.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3964688.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "3970827.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "3980001.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "3981797.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "3983645.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "3983646.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "400259.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4004314.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4004668.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4005630.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4006551.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4012083.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4014747.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4014749.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4017608.LOFI.mp3", + "expected_bpm": 101.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4017611.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4017781.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4017892.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4017909.LOFI.mp3", + "expected_bpm": 112.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4018082.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4018083.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4018084.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4018085.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4018247.LOFI.mp3", + "expected_bpm": 96.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "4020698.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4029289.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4029970.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4031300.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4032006.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4033240.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4035020.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4038987.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4043892.LOFI.mp3", + "expected_bpm": 183.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4044590.LOFI.mp3", + "expected_bpm": 100.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4044591.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4045981.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4047428.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "4067151.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4071447.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4072499.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4077896.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4086561.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4091609.LOFI.mp3", + "expected_bpm": 80.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "4093057.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4093135.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4093555.LOFI.mp3", + "expected_bpm": 92.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4101363.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4101447.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4106593.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4107957.LOFI.mp3", + "expected_bpm": 93.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "4117487.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4120277.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4122833.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4136911.LOFI.mp3", + "expected_bpm": 153.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "4140514.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4140702.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "4145229.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4149743.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4149811.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4151958.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4152109.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4153394.LOFI.mp3", + "expected_bpm": 80.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "4157075.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4161667.LOFI.mp3", + "expected_bpm": 106.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4162259.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4162274.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4162405.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4162511.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4163571.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4163572.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4163586.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4163587.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4163588.LOFI.mp3", + "expected_bpm": 90.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4166222.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4166612.LOFI.mp3", + "expected_bpm": 124.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4172572.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4186191.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4191591.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4192452.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4193623.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4207348.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4214761.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4216184.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4218466.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4226166.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4226227.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4226434.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4231951.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4233696.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4235798.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4237913.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4264210.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4264359.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4265125.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "426992.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4275052.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4279669.LOFI.mp3", + "expected_bpm": 82.0, + "tolerance_percent": 2.0, + "genre": "indie-dance-nu-disco", + "notes": "" + }, + { + "filename": "4283622.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4283854.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4284345.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4288893.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4297277.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4300617.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4300619.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4301518.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4304445.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4313859.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4315749.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4323506.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4325441.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4331667.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4332592.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4346730.LOFI.mp3", + "expected_bpm": 86.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4353566.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4358231.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4360483.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4360488.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4365752.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4366506.LOFI.mp3", + "expected_bpm": 197.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4372309.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4377106.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4381717.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4386702.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4397324.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4397469.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4398117.LOFI.mp3", + "expected_bpm": 179.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4399289.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4403315.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4403519.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4404346.LOFI.mp3", + "expected_bpm": 188.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "4407745.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4409752.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4411925.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4416336.LOFI.mp3", + "expected_bpm": 188.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "4416506.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4416888.LOFI.mp3", + "expected_bpm": 89.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4416962.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4418582.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4419049.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4420924.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4426890.LOFI.mp3", + "expected_bpm": 106.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4427618.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4442121.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4446908.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "4457771.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4459005.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4459187.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4460536.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4468729.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4469489.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4469780.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4471292.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4471702.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4473065.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4474027.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4474029.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4475855.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4480118.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "4480454.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4483708.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4486124.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4486412.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4489017.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4493439.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4494659.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4495270.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4497852.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4508138.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "4509910.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4512512.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4514851.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4517258.LOFI.mp3", + "expected_bpm": 82.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4532060.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4540818.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4543103.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4547732.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4549757.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4558106.LOFI.mp3", + "expected_bpm": 128.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4559522.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4565270.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4566667.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4567978.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4567979.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4585539.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4586220.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4593850.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4596215.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4604737.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "4609093.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4609944.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "4610426.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4611640.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4623143.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4624663.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4625875.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4631727.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4633603.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "hip-hop", + "notes": "" + }, + { + "filename": "4650113.LOFI.mp3", + "expected_bpm": 117.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4653852.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4661735.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4666231.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4667626.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4671406.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4671443.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4677606.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4678080.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4693039.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "progressive-house", + "notes": "" + }, + { + "filename": "4693198.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4694833.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4696505.LOFI.mp3", + "expected_bpm": 160.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4703149.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4710576.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4711578.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4714481.LOFI.mp3", + "expected_bpm": 112.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4723905.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4735587.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4735758.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4743012.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4749611.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "4767260.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4772658.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4773857.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4773948.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4800274.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4800275.LOFI.mp3", + "expected_bpm": 141.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4810810.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4816604.LOFI.mp3", + "expected_bpm": 70.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4823439.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4823611.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "4826952.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4826957.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4827285.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4827287.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4845088.LOFI.mp3", + "expected_bpm": 90.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4850346.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "deep-house", + "notes": "" + }, + { + "filename": "4853692.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4860099.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4861484.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4863146.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "4875475.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "4884993.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4896659.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4899365.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4921810.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "4936740.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "4937390.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "4947281.LOFI.mp3", + "expected_bpm": 136.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4951739.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "4955051.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4955053.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "4960424.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "4969415.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4984238.LOFI.mp3", + "expected_bpm": 116.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "4993934.LOFI.mp3", + "expected_bpm": 140.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "5024644.LOFI.mp3", + "expected_bpm": 142.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5039329.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5051606.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "5068771.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5070846.LOFI.mp3", + "expected_bpm": 110.0, + "tolerance_percent": 2.0, + "genre": "glitch-hop", + "notes": "" + }, + { + "filename": "5073839.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5076753.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "5076821.LOFI.mp3", + "expected_bpm": 169.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5078640.LOFI.mp3", + "expected_bpm": 172.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5079918.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "5081990.LOFI.mp3", + "expected_bpm": 85.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "5089294.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5102537.LOFI.mp3", + "expected_bpm": 123.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "5117279.LOFI.mp3", + "expected_bpm": 119.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "5118757.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "5135326.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "5137153.LOFI.mp3", + "expected_bpm": 130.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5137154.LOFI.mp3", + "expected_bpm": 134.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5137157.LOFI.mp3", + "expected_bpm": 153.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5137158.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5143186.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "5157605.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "5160584.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "5162042.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5171396.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5195826.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5202182.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "5204781.LOFI.mp3", + "expected_bpm": 125.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "5213896.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5214265.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "5214755.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5214943.LOFI.mp3", + "expected_bpm": 74.0, + "tolerance_percent": 2.0, + "genre": "chill-out", + "notes": "" + }, + { + "filename": "5237462.LOFI.mp3", + "expected_bpm": 121.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "5248391.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5258365.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "5261461.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "house", + "notes": "" + }, + { + "filename": "5268098.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "5294335.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "tech-house", + "notes": "" + }, + { + "filename": "5302339.LOFI.mp3", + "expected_bpm": 163.0, + "tolerance_percent": 2.0, + "genre": "breaks", + "notes": "" + }, + { + "filename": "5314439.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "5315510.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "5335389.LOFI.mp3", + "expected_bpm": 71.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "5345058.LOFI.mp3", + "expected_bpm": 147.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "5347155.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5355157.LOFI.mp3", + "expected_bpm": 167.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "5356489.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "psy-trance", + "notes": "" + }, + { + "filename": "5363251.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "536656.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "5377710.LOFI.mp3", + "expected_bpm": 129.0, + "tolerance_percent": 2.0, + "genre": "electro-house", + "notes": "" + }, + { + "filename": "5406759.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "minimal", + "notes": "" + }, + { + "filename": "5431709.LOFI.mp3", + "expected_bpm": 87.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "547692.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "587794.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "633082.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "672063.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "672064.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "691688.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "691694.LOFI.mp3", + "expected_bpm": 144.0, + "tolerance_percent": 2.0, + "genre": "hard-dance", + "notes": "" + }, + { + "filename": "710469.LOFI.mp3", + "expected_bpm": 156.0, + "tolerance_percent": 2.0, + "genre": "hardcore-hard-techno", + "notes": "" + }, + { + "filename": "750132.LOFI.mp3", + "expected_bpm": 132.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "825292.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "861484.LOFI.mp3", + "expected_bpm": 171.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "906680.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "906681.LOFI.mp3", + "expected_bpm": 172.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "906760.LOFI.mp3", + "expected_bpm": 104.0, + "tolerance_percent": 2.0, + "genre": "dj-tools", + "notes": "" + }, + { + "filename": "907836.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "907837.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "925895.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "dubstep", + "notes": "" + }, + { + "filename": "942357.LOFI.mp3", + "expected_bpm": 170.0, + "tolerance_percent": 2.0, + "genre": "electronica", + "notes": "" + }, + { + "filename": "94350.LOFI.mp3", + "expected_bpm": 150.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + }, + { + "filename": "956606.LOFI.mp3", + "expected_bpm": 127.0, + "tolerance_percent": 2.0, + "genre": "techno", + "notes": "" + }, + { + "filename": "986931.LOFI.mp3", + "expected_bpm": 139.0, + "tolerance_percent": 2.0, + "genre": "trance", + "notes": "" + }, + { + "filename": "989704.LOFI.mp3", + "expected_bpm": 174.0, + "tolerance_percent": 2.0, + "genre": "drum-and-bass", + "notes": "" + } + ] +} \ No newline at end of file diff --git a/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.cpp b/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.cpp new file mode 100644 index 0000000..72812b2 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.cpp @@ -0,0 +1,498 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2008-2009 Matthew Davies and QMUL. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "TempoTrackV2.h" + +#include +#include +#include + +#include "maths/MathUtilities.h" + +using std::vector; + +#define EPS 0.0000008 // just some arbitrary small number + +TempoTrackV2::TempoTrackV2(float rate, int increment) : + m_rate(rate), m_increment(increment) { +} + +TempoTrackV2::~TempoTrackV2() { } + +void +TempoTrackV2::filter_df(d_vec_t &df) +{ + int df_len = int(df.size()); + + d_vec_t a(3); + d_vec_t b(3); + d_vec_t lp_df(df_len); + + //equivalent in matlab to [b,a] = butter(2,0.4); + a[0] = 1.0000; + a[1] = -0.3695; + a[2] = 0.1958; + b[0] = 0.2066; + b[1] = 0.4131; + b[2] = 0.2066; + + double inp1 = 0.; + double inp2 = 0.; + double out1 = 0.; + double out2 = 0.; + + + // forwards filtering + for (int i = 0; i < df_len; i++) { + lp_df[i] = b[0]*df[i] + b[1]*inp1 + b[2]*inp2 - a[1]*out1 - a[2]*out2; + inp2 = inp1; + inp1 = df[i]; + out2 = out1; + out1 = lp_df[i]; + } + + // copy forwards filtering to df... + // but, time-reversed, ready for backwards filtering + for (int i = 0; i < df_len; i++) { + df[i] = lp_df[df_len - i - 1]; + } + + for (int i = 0; i < df_len; i++) { + lp_df[i] = 0.; + } + + inp1 = 0.; inp2 = 0.; + out1 = 0.; out2 = 0.; + + // backwards filetering on time-reversed df + for (int i = 0; i < df_len; i++) { + lp_df[i] = b[0]*df[i] + b[1]*inp1 + b[2]*inp2 - a[1]*out1 - a[2]*out2; + inp2 = inp1; + inp1 = df[i]; + out2 = out1; + out1 = lp_df[i]; + } + + // write the re-reversed (i.e. forward) version back to df + for (int i = 0; i < df_len; i++) { + df[i] = lp_df[df_len - i - 1]; + } +} + + +// MEPD 28/11/12 +// This function now allows for a user to specify an inputtempo (in BPM) +// and a flag "constraintempo" which replaces the general rayleigh weighting for periodicities +// with a gaussian which is centered around the input tempo +// Note, if inputtempo = 120 and constraintempo = false, then functionality is +// as it was before +void +TempoTrackV2::calculateBeatPeriod(const vector &df, + vector &beat_period, + vector &tempi, + double inputtempo, bool constraintempo) +{ + // to follow matlab.. split into 512 sample frames with a 128 hop size + // calculate the acf, + // then the rcf.. and then stick the rcfs as columns of a matrix + // then call viterbi decoding with weight vector and transition matrix + // and get best path + + int wv_len = 128; + + // MEPD 28/11/12 + // the default value of inputtempo in the beat tracking plugin is 120 + // so if the user specifies a different inputtempo, the rayparam will be updated + // accordingly. + // note: 60*44100/512 is a magic number + // this might (will?) break if a user specifies a different frame rate for the onset detection function + double rayparam = (60*44100/512)/inputtempo; + + // make rayleigh weighting curve + d_vec_t wv(wv_len); + + // check whether or not to use rayleigh weighting (if constraintempo is false) + // or use gaussian weighting it (constraintempo is true) + if (constraintempo) { + for (int i = 0; i < wv_len; i++) { + // MEPD 28/11/12 + // do a gaussian weighting instead of rayleigh + wv[i] = exp( (-1.*pow((double(i)-rayparam),2.)) / (2.*pow(rayparam/4.,2.)) ); + } + } else { + for (int i = 0; i < wv_len; i++) { + // MEPD 28/11/12 + // standard rayleigh weighting over periodicities + wv[i] = (double(i) / pow(rayparam,2.)) * exp((-1.*pow(-double(i),2.)) / (2.*pow(rayparam,2.))); + } + } + + // beat tracking frame size (roughly 6 seconds) and hop (1.5 seconds) + int winlen = 512; + int step = 128; + + // matrix to store output of comb filter bank, increment column of matrix at each frame + d_mat_t rcfmat; + int col_counter = -1; + int df_len = int(df.size()); + + // main loop for beat period calculation + for (int i = 0; i+winlen < df_len; i+=step) { + + // get dfframe + d_vec_t dfframe(winlen); + for (int k=0; k < winlen; k++) { + dfframe[k] = df[i+k]; + } + // get rcf vector for current frame + d_vec_t rcf(wv_len); + get_rcf(dfframe,wv,rcf); + + rcfmat.push_back( d_vec_t() ); // adds a new column + col_counter++; + for (int j = 0; j < wv_len; j++) { + rcfmat[col_counter].push_back( rcf[j] ); + } + } + + // now call viterbi decoding function + viterbi_decode(rcfmat,wv,beat_period,tempi); +} + + +void +TempoTrackV2::get_rcf(const d_vec_t &dfframe_in, const d_vec_t &wv, d_vec_t &rcf) +{ + // calculate autocorrelation function + // then rcf + // just hard code for now... don't really need separate functions to do this + + // make acf + + d_vec_t dfframe(dfframe_in); + + MathUtilities::adaptiveThreshold(dfframe); + + int dfframe_len = int(dfframe.size()); + int rcf_len = int(rcf.size()); + + d_vec_t acf(dfframe_len); + + for (int lag = 0; lag < dfframe_len; lag++) { + double sum = 0.; + double tmp = 0.; + + for (int n = 0; n < (dfframe_len - lag); n++) { + tmp = dfframe[n] * dfframe[n + lag]; + sum += tmp; + } + acf[lag] = double(sum/ (dfframe_len - lag)); + } + + // now apply comb filtering + int numelem = 4; + + for (int i = 2; i < rcf_len; i++) { // max beat period + for (int a = 1; a <= numelem; a++) { // number of comb elements + for (int b = 1-a; b <= a-1; b++) { // general state using normalisation of comb elements + rcf[i-1] += ( acf[(a*i+b)-1]*wv[i-1] ) / (2.*a-1.); // calculate value for comb filter row + } + } + } + + // apply adaptive threshold to rcf + MathUtilities::adaptiveThreshold(rcf); + + double rcfsum =0.; + for (int i = 0; i < rcf_len; i++) { + rcf[i] += EPS ; + rcfsum += rcf[i]; + } + + // normalise rcf to sum to unity + for (int i = 0; i < rcf_len; i++) { + rcf[i] /= (rcfsum + EPS); + } +} + +void +TempoTrackV2::viterbi_decode(const d_mat_t &rcfmat, const d_vec_t &wv, d_vec_t &beat_period, d_vec_t &tempi) +{ + // following Kevin Murphy's Viterbi decoding to get best path of + // beat periods through rfcmat + + int wv_len = int(wv.size()); + + // make transition matrix + d_mat_t tmat; + for (int i = 0; i < wv_len; i++) { + tmat.push_back ( d_vec_t() ); // adds a new column + for (int j = 0; j < wv_len; j++) { + tmat[i].push_back(0.); // fill with zeros initially + } + } + + // variance of Gaussians in transition matrix + // formed of Gaussians on diagonal - implies slow tempo change + double sigma = 8.; + // don't want really short beat periods, or really long ones + for (int i = 20; i < wv_len - 20; i++) { + for (int j = 20; j < wv_len - 20; j++) { + double mu = double(i); + tmat[i][j] = exp( (-1.*pow((j-mu),2.)) / (2.*pow(sigma,2.)) ); + } + } + + // parameters for Viterbi decoding... this part is taken from + // Murphy's matlab + + d_mat_t delta; + i_mat_t psi; + for (int i = 0; i < int(rcfmat.size()); i++) { + delta.push_back(d_vec_t()); + psi.push_back(i_vec_t()); + for (int j = 0; j < int(rcfmat[i].size()); j++) { + delta[i].push_back(0.); // fill with zeros initially + psi[i].push_back(0); // fill with zeros initially + } + } + + int T = int(delta.size()); + + if (T < 2) return; // can't do anything at all meaningful + + int Q = int(delta[0].size()); + + // initialize first column of delta + for (int j = 0; j < Q; j++) { + delta[0][j] = wv[j] * rcfmat[0][j]; + psi[0][j] = 0; + } + + double deltasum = 0.; + for (int i = 0; i < Q; i++) { + deltasum += delta[0][i]; + } + for (int i = 0; i < Q; i++) { + delta[0][i] /= (deltasum + EPS); + } + + for (int t=1; t < T; t++) + { + d_vec_t tmp_vec(Q); + + for (int j = 0; j < Q; j++) { + for (int i = 0; i < Q; i++) { + tmp_vec[i] = delta[t-1][i] * tmat[j][i]; + } + + delta[t][j] = get_max_val(tmp_vec); + + psi[t][j] = get_max_ind(tmp_vec); + + delta[t][j] *= rcfmat[t][j]; + } + + // normalise current delta column + double deltasum = 0.; + for (int i = 0; i < Q; i++) { + deltasum += delta[t][i]; + } + for (int i = 0; i < Q; i++) { + delta[t][i] /= (deltasum + EPS); + } + } + + i_vec_t bestpath(T); + d_vec_t tmp_vec(Q); + for (int i = 0; i < Q; i++) { + tmp_vec[i] = delta[T-1][i]; + } + + // find starting point - best beat period for "last" frame + bestpath[T-1] = get_max_ind(tmp_vec); + + // backtrace through index of maximum values in psi + for (int t=T-2; t>0 ;t--) { + bestpath[t] = psi[t+1][bestpath[t+1]]; + } + + // weird but necessary hack -- couldn't get above loop to terminate at t >= 0 + bestpath[0] = psi[1][bestpath[1]]; + + int lastind = 0; + for (int i = 0; i < T; i++) { + int step = 128; + for (int j = 0; j < step; j++) { + lastind = i*step+j; + beat_period[lastind] = bestpath[i]; + } +// std::cerr << "bestpath[" << i << "] = " << bestpath[i] << " (used for beat_periods " << i*step << " to " << i*step+step-1 << ")" << std::endl; + } + + // fill in the last values... + for (int i = lastind; i < int(beat_period.size()); i++) { + beat_period[i] = beat_period[lastind]; + } + + for (int i = 0; i < int(beat_period.size()); i++) { + tempi.push_back((60. * m_rate / m_increment)/beat_period[i]); + } +} + +double +TempoTrackV2::get_max_val(const d_vec_t &df) +{ + double maxval = 0.; + int df_len = int(df.size()); + + for (int i = 0; i < df_len; i++) { + if (maxval < df[i]) { + maxval = df[i]; + } + } + + return maxval; +} + +int +TempoTrackV2::get_max_ind(const d_vec_t &df) +{ + double maxval = 0.; + int ind = 0; + int df_len = int(df.size()); + + for (int i = 0; i < df_len; i++) { + if (maxval < df[i]) { + maxval = df[i]; + ind = i; + } + } + + return ind; +} + +void +TempoTrackV2::normalise_vec(d_vec_t &df) +{ + double sum = 0.; + int df_len = int(df.size()); + + for (int i = 0; i < df_len; i++) { + sum += df[i]; + } + + for (int i = 0; i < df_len; i++) { + df[i]/= (sum + EPS); + } +} + +// MEPD 28/11/12 +// this function has been updated to allow the "alpha" and "tightness" parameters +// of the dynamic program to be set by the user +// the default value of alpha = 0.9 and tightness = 4 +void +TempoTrackV2::calculateBeats(const vector &df, + const vector &beat_period, + vector &beats, double alpha, double tightness) +{ + if (df.empty() || beat_period.empty()) return; + + int df_len = int(df.size()); + + d_vec_t cumscore(df_len); // store cumulative score + i_vec_t backlink(df_len); // backlink (stores best beat locations at each time instant) + d_vec_t localscore(df_len); // localscore, for now this is the same as the detection function + + for (int i = 0; i < df_len; i++) { + localscore[i] = df[i]; + backlink[i] = -1; + } + + //double tightness = 4.; + //double alpha = 0.9; + // MEPD 28/11/12 + // debug statements that can be removed. +// std::cerr << "alpha" << alpha << std::endl; +// std::cerr << "tightness" << tightness << std::endl; + + // main loop + for (int i = 0; i < df_len; i++) { + + int prange_min = -2*beat_period[i]; + int prange_max = round(-0.5*beat_period[i]); + + // transition range + int txwt_len = prange_max - prange_min + 1; + d_vec_t txwt (txwt_len); + d_vec_t scorecands (txwt_len); + + for (int j = 0; j < txwt_len; j++) { + + double mu = double(beat_period[i]); + txwt[j] = exp( -0.5*pow(tightness * log((round(2*mu)-j)/mu),2)); + + // IF IN THE ALLOWED RANGE, THEN LOOK AT CUMSCORE[I+PRANGE_MIN+J + // ELSE LEAVE AT DEFAULT VALUE FROM INITIALISATION: D_VEC_T SCORECANDS (TXWT.SIZE()); + + int cscore_ind = i + prange_min + j; + if (cscore_ind >= 0) { + scorecands[j] = txwt[j] * cumscore[cscore_ind]; + } + } + + // find max value and index of maximum value + double vv = get_max_val(scorecands); + int xx = get_max_ind(scorecands); + + cumscore[i] = alpha*vv + (1.-alpha)*localscore[i]; + backlink[i] = i+prange_min+xx; + +// std::cerr << "backlink[" << i << "] <= " << backlink[i] << std::endl; + } + + // STARTING POINT, I.E. LAST BEAT.. PICK A STRONG POINT IN cumscore VECTOR + d_vec_t tmp_vec; + for (int i = df_len - beat_period[beat_period.size()-1] ; i < df_len; i++) { + tmp_vec.push_back(cumscore[i]); + } + + int startpoint = get_max_ind(tmp_vec) + + df_len - beat_period[beat_period.size()-1] ; + + // can happen if no results obtained earlier (e.g. input too short) + if (startpoint >= int(backlink.size())) { + startpoint = int(backlink.size()) - 1; + } + + // USE BACKLINK TO GET EACH NEW BEAT (TOWARDS THE BEGINNING OF THE FILE) + // BACKTRACKING FROM THE END TO THE BEGINNING.. MAKING SURE NOT TO GO BEFORE SAMPLE 0 + i_vec_t ibeats; + ibeats.push_back(startpoint); +// std::cerr << "startpoint = " << startpoint << std::endl; + while (backlink[ibeats.back()] > 0) { +// std::cerr << "backlink[" << ibeats.back() << "] = " << backlink[ibeats.back()] << std::endl; + int b = ibeats.back(); + if (backlink[b] == b) break; // shouldn't happen... haha + ibeats.push_back(backlink[b]); + } + + // REVERSE SEQUENCE OF IBEATS AND STORE AS BEATS + for (int i = 0; i < int(ibeats.size()); i++) { + beats.push_back(double(ibeats[ibeats.size() - i - 1])); + } +} + + diff --git a/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.h b/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.h new file mode 100644 index 0000000..accbb9e --- /dev/null +++ b/crates/dj/vendor/qm-dsp/dsp/tempotracking/TempoTrackV2.h @@ -0,0 +1,90 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file copyright 2008-2009 Matthew Davies and QMUL. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef QM_DSP_TEMPOTRACKV2_H +#define QM_DSP_TEMPOTRACKV2_H + +#include + +//!!! Question: how far is this actually sample rate dependent? I +// think it does produce plausible results for e.g. 48000 as well as +// 44100, but surely the fixed window sizes and comb filtering will +// make it prefer double or half time when run at e.g. 96000? + +class TempoTrackV2 +{ +public: + /** + * Construct a tempo tracker that will operate on beat detection + * function data calculated from audio at the given sample rate + * with the given frame increment. + * + * Currently the sample rate and increment are used only for the + * conversion from beat frame location to bpm in the tempo array. + */ + TempoTrackV2(float sampleRate, int dfIncrement); + ~TempoTrackV2(); + + // Returned beat periods are given in df increment units; inputtempo and tempi in bpm + void calculateBeatPeriod(const std::vector &df, + std::vector &beatPeriod, + std::vector &tempi) { + calculateBeatPeriod(df, beatPeriod, tempi, 120.0, false); + } + + // Returned beat periods are given in df increment units; inputtempo and tempi in bpm + // MEPD 28/11/12 Expose inputtempo and constraintempo parameters + // Note, if inputtempo = 120 and constraintempo = false, then functionality is as it was before + void calculateBeatPeriod(const std::vector &df, + std::vector &beatPeriod, + std::vector &tempi, + double inputtempo, bool constraintempo); + + // Returned beat positions are given in df increment units + void calculateBeats(const std::vector &df, + const std::vector &beatPeriod, + std::vector &beats) { + calculateBeats(df, beatPeriod, beats, 0.9, 4.0); + } + + // Returned beat positions are given in df increment units + // MEPD 28/11/12 Expose alpha and tightness parameters + // Note, if alpha = 0.9 and tightness = 4, then functionality is as it was before + void calculateBeats(const std::vector &df, + const std::vector &beatPeriod, + std::vector &beats, + double alpha, double tightness); + +private: + typedef std::vector i_vec_t; + typedef std::vector > i_mat_t; + typedef std::vector d_vec_t; + typedef std::vector > d_mat_t; + + float m_rate; + int m_increment; + + void adapt_thresh(d_vec_t &df); + double mean_array(const d_vec_t &dfin, int start, int end); + void filter_df(d_vec_t &df); + void get_rcf(const d_vec_t &dfframe, const d_vec_t &wv, d_vec_t &rcf); + void viterbi_decode(const d_mat_t &rcfmat, const d_vec_t &wv, + d_vec_t &bp, d_vec_t &tempi); + double get_max_val(const d_vec_t &df); + int get_max_ind(const d_vec_t &df); + void normalise_vec(d_vec_t &df); +}; + +#endif diff --git a/crates/dj/vendor/qm-dsp/ext/kissfft/_kiss_fft_guts.h b/crates/dj/vendor/qm-dsp/ext/kissfft/_kiss_fft_guts.h new file mode 100644 index 0000000..ba66144 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/ext/kissfft/_kiss_fft_guts.h @@ -0,0 +1,164 @@ +/* +Copyright (c) 2003-2010, Mark Borgerding + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the author nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +/* kiss_fft.h + defines kiss_fft_scalar as either short or a float type + and defines + typedef struct { kiss_fft_scalar r; kiss_fft_scalar i; }kiss_fft_cpx; */ +#include "kiss_fft.h" +#include + +#define MAXFACTORS 32 +/* e.g. an fft of length 128 has 4 factors + as far as kissfft is concerned + 4*4*4*2 + */ + +struct kiss_fft_state{ + int nfft; + int inverse; + int factors[2*MAXFACTORS]; + kiss_fft_cpx twiddles[1]; +}; + +/* + Explanation of macros dealing with complex math: + + C_MUL(m,a,b) : m = a*b + C_FIXDIV( c , div ) : if a fixed point impl., c /= div. noop otherwise + C_SUB( res, a,b) : res = a - b + C_SUBFROM( res , a) : res -= a + C_ADDTO( res , a) : res += a + * */ +#ifdef FIXED_POINT +#if (FIXED_POINT==32) +# define FRACBITS 31 +# define SAMPPROD int64_t +#define SAMP_MAX 2147483647 +#else +# define FRACBITS 15 +# define SAMPPROD int32_t +#define SAMP_MAX 32767 +#endif + +#define SAMP_MIN -SAMP_MAX + +#if defined(CHECK_OVERFLOW) +# define CHECK_OVERFLOW_OP(a,op,b) \ + if ( (SAMPPROD)(a) op (SAMPPROD)(b) > SAMP_MAX || (SAMPPROD)(a) op (SAMPPROD)(b) < SAMP_MIN ) { \ + fprintf(stderr,"WARNING:overflow @ " __FILE__ "(%d): (%d " #op" %d) = %ld\n",__LINE__,(a),(b),(SAMPPROD)(a) op (SAMPPROD)(b) ); } +#endif + + +# define smul(a,b) ( (SAMPPROD)(a)*(b) ) +# define sround( x ) (kiss_fft_scalar)( ( (x) + (1<<(FRACBITS-1)) ) >> FRACBITS ) + +# define S_MUL(a,b) sround( smul(a,b) ) + +# define C_MUL(m,a,b) \ + do{ (m).r = sround( smul((a).r,(b).r) - smul((a).i,(b).i) ); \ + (m).i = sround( smul((a).r,(b).i) + smul((a).i,(b).r) ); }while(0) + +# define DIVSCALAR(x,k) \ + (x) = sround( smul( x, SAMP_MAX/k ) ) + +# define C_FIXDIV(c,div) \ + do { DIVSCALAR( (c).r , div); \ + DIVSCALAR( (c).i , div); }while (0) + +# define C_MULBYSCALAR( c, s ) \ + do{ (c).r = sround( smul( (c).r , s ) ) ;\ + (c).i = sround( smul( (c).i , s ) ) ; }while(0) + +#else /* not FIXED_POINT*/ + +# define S_MUL(a,b) ( (a)*(b) ) +#define C_MUL(m,a,b) \ + do{ (m).r = (a).r*(b).r - (a).i*(b).i;\ + (m).i = (a).r*(b).i + (a).i*(b).r; }while(0) +# define C_FIXDIV(c,div) /* NOOP */ +# define C_MULBYSCALAR( c, s ) \ + do{ (c).r *= (s);\ + (c).i *= (s); }while(0) +#endif + +#ifndef CHECK_OVERFLOW_OP +# define CHECK_OVERFLOW_OP(a,op,b) /* noop */ +#endif + +#define C_ADD( res, a,b)\ + do { \ + CHECK_OVERFLOW_OP((a).r,+,(b).r)\ + CHECK_OVERFLOW_OP((a).i,+,(b).i)\ + (res).r=(a).r+(b).r; (res).i=(a).i+(b).i; \ + }while(0) +#define C_SUB( res, a,b)\ + do { \ + CHECK_OVERFLOW_OP((a).r,-,(b).r)\ + CHECK_OVERFLOW_OP((a).i,-,(b).i)\ + (res).r=(a).r-(b).r; (res).i=(a).i-(b).i; \ + }while(0) +#define C_ADDTO( res , a)\ + do { \ + CHECK_OVERFLOW_OP((res).r,+,(a).r)\ + CHECK_OVERFLOW_OP((res).i,+,(a).i)\ + (res).r += (a).r; (res).i += (a).i;\ + }while(0) + +#define C_SUBFROM( res , a)\ + do {\ + CHECK_OVERFLOW_OP((res).r,-,(a).r)\ + CHECK_OVERFLOW_OP((res).i,-,(a).i)\ + (res).r -= (a).r; (res).i -= (a).i; \ + }while(0) + + +#ifdef FIXED_POINT +# define KISS_FFT_COS(phase) floor(.5+SAMP_MAX * cos (phase)) +# define KISS_FFT_SIN(phase) floor(.5+SAMP_MAX * sin (phase)) +# define HALF_OF(x) ((x)>>1) +#elif defined(USE_SIMD) +# define KISS_FFT_COS(phase) _mm_set1_ps( cos(phase) ) +# define KISS_FFT_SIN(phase) _mm_set1_ps( sin(phase) ) +# define HALF_OF(x) ((x)*_mm_set1_ps(.5)) +#else +# define KISS_FFT_COS(phase) (kiss_fft_scalar) cos(phase) +# define KISS_FFT_SIN(phase) (kiss_fft_scalar) sin(phase) +# define HALF_OF(x) ((x)*.5) +#endif + +#define kf_cexp(x,phase) \ + do{ \ + (x)->r = KISS_FFT_COS(phase);\ + (x)->i = KISS_FFT_SIN(phase);\ + }while(0) + + +/* a debugging function */ +#define pcpx(c)\ + fprintf(stderr,"%g + %gi\n",(double)((c)->r),(double)((c)->i) ) + + +#ifdef KISS_FFT_USE_ALLOCA +// define this to allow use of alloca instead of malloc for temporary buffers +// Temporary buffers are used in two case: +// 1. FFT sizes that have "bad" factors. i.e. not 2,3 and 5 +// 2. "in-place" FFTs. Notice the quotes, since kissfft does not really do an in-place transform. +#include +#define KISS_FFT_TMP_ALLOC(nbytes) alloca(nbytes) +#define KISS_FFT_TMP_FREE(ptr) +#else +#define KISS_FFT_TMP_ALLOC(nbytes) KISS_FFT_MALLOC(nbytes) +#define KISS_FFT_TMP_FREE(ptr) KISS_FFT_FREE(ptr) +#endif diff --git a/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.c b/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.c new file mode 100644 index 0000000..7824d34 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.c @@ -0,0 +1,409 @@ +/* +Copyright (c) 2003-2010, Mark Borgerding + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the author nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + + +#include "_kiss_fft_guts.h" +/* The guts header contains all the multiplication and addition macros that are defined for + fixed or floating point complex numbers. It also delares the kf_ internal functions. + */ + +static void kf_bfly2( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m + ) +{ + kiss_fft_cpx * Fout2; + kiss_fft_cpx * tw1 = st->twiddles; + kiss_fft_cpx t; + Fout2 = Fout + m; + do{ + C_FIXDIV(*Fout,2); C_FIXDIV(*Fout2,2); + + C_MUL (t, *Fout2 , *tw1); + tw1 += fstride; + C_SUB( *Fout2 , *Fout , t ); + C_ADDTO( *Fout , t ); + ++Fout2; + ++Fout; + }while (--m); +} + +static void kf_bfly4( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + const size_t m + ) +{ + kiss_fft_cpx *tw1,*tw2,*tw3; + kiss_fft_cpx scratch[6]; + size_t k=m; + const size_t m2=2*m; + const size_t m3=3*m; + + + tw3 = tw2 = tw1 = st->twiddles; + + do { + C_FIXDIV(*Fout,4); C_FIXDIV(Fout[m],4); C_FIXDIV(Fout[m2],4); C_FIXDIV(Fout[m3],4); + + C_MUL(scratch[0],Fout[m] , *tw1 ); + C_MUL(scratch[1],Fout[m2] , *tw2 ); + C_MUL(scratch[2],Fout[m3] , *tw3 ); + + C_SUB( scratch[5] , *Fout, scratch[1] ); + C_ADDTO(*Fout, scratch[1]); + C_ADD( scratch[3] , scratch[0] , scratch[2] ); + C_SUB( scratch[4] , scratch[0] , scratch[2] ); + C_SUB( Fout[m2], *Fout, scratch[3] ); + tw1 += fstride; + tw2 += fstride*2; + tw3 += fstride*3; + C_ADDTO( *Fout , scratch[3] ); + + if(st->inverse) { + Fout[m].r = scratch[5].r - scratch[4].i; + Fout[m].i = scratch[5].i + scratch[4].r; + Fout[m3].r = scratch[5].r + scratch[4].i; + Fout[m3].i = scratch[5].i - scratch[4].r; + }else{ + Fout[m].r = scratch[5].r + scratch[4].i; + Fout[m].i = scratch[5].i - scratch[4].r; + Fout[m3].r = scratch[5].r - scratch[4].i; + Fout[m3].i = scratch[5].i + scratch[4].r; + } + ++Fout; + }while(--k); +} + +static void kf_bfly3( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + size_t m + ) +{ + size_t k=m; + const size_t m2 = 2*m; + kiss_fft_cpx *tw1,*tw2; + kiss_fft_cpx scratch[5]; + kiss_fft_cpx epi3; + epi3 = st->twiddles[fstride*m]; + + tw1=tw2=st->twiddles; + + do{ + C_FIXDIV(*Fout,3); C_FIXDIV(Fout[m],3); C_FIXDIV(Fout[m2],3); + + C_MUL(scratch[1],Fout[m] , *tw1); + C_MUL(scratch[2],Fout[m2] , *tw2); + + C_ADD(scratch[3],scratch[1],scratch[2]); + C_SUB(scratch[0],scratch[1],scratch[2]); + tw1 += fstride; + tw2 += fstride*2; + + Fout[m].r = Fout->r - HALF_OF(scratch[3].r); + Fout[m].i = Fout->i - HALF_OF(scratch[3].i); + + C_MULBYSCALAR( scratch[0] , epi3.i ); + + C_ADDTO(*Fout,scratch[3]); + + Fout[m2].r = Fout[m].r + scratch[0].i; + Fout[m2].i = Fout[m].i - scratch[0].r; + + Fout[m].r -= scratch[0].i; + Fout[m].i += scratch[0].r; + + ++Fout; + }while(--k); +} + +static void kf_bfly5( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m + ) +{ + kiss_fft_cpx *Fout0,*Fout1,*Fout2,*Fout3,*Fout4; + int u; + kiss_fft_cpx scratch[13]; + kiss_fft_cpx * twiddles = st->twiddles; + kiss_fft_cpx *tw; + kiss_fft_cpx ya,yb; + ya = twiddles[fstride*m]; + yb = twiddles[fstride*2*m]; + + Fout0=Fout; + Fout1=Fout0+m; + Fout2=Fout0+2*m; + Fout3=Fout0+3*m; + Fout4=Fout0+4*m; + + tw=st->twiddles; + for ( u=0; ur += scratch[7].r + scratch[8].r; + Fout0->i += scratch[7].i + scratch[8].i; + + scratch[5].r = scratch[0].r + S_MUL(scratch[7].r,ya.r) + S_MUL(scratch[8].r,yb.r); + scratch[5].i = scratch[0].i + S_MUL(scratch[7].i,ya.r) + S_MUL(scratch[8].i,yb.r); + + scratch[6].r = S_MUL(scratch[10].i,ya.i) + S_MUL(scratch[9].i,yb.i); + scratch[6].i = -S_MUL(scratch[10].r,ya.i) - S_MUL(scratch[9].r,yb.i); + + C_SUB(*Fout1,scratch[5],scratch[6]); + C_ADD(*Fout4,scratch[5],scratch[6]); + + scratch[11].r = scratch[0].r + S_MUL(scratch[7].r,yb.r) + S_MUL(scratch[8].r,ya.r); + scratch[11].i = scratch[0].i + S_MUL(scratch[7].i,yb.r) + S_MUL(scratch[8].i,ya.r); + scratch[12].r = - S_MUL(scratch[10].i,yb.i) + S_MUL(scratch[9].i,ya.i); + scratch[12].i = S_MUL(scratch[10].r,yb.i) - S_MUL(scratch[9].r,ya.i); + + C_ADD(*Fout2,scratch[11],scratch[12]); + C_SUB(*Fout3,scratch[11],scratch[12]); + + ++Fout0;++Fout1;++Fout2;++Fout3;++Fout4; + } +} + +/* perform the butterfly for one stage of a mixed radix FFT */ +static void kf_bfly_generic( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m, + int p + ) +{ + int u,k,q1,q; + kiss_fft_cpx * twiddles = st->twiddles; + kiss_fft_cpx t; + int Norig = st->nfft; + + kiss_fft_cpx * scratch = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC(sizeof(kiss_fft_cpx)*p); + + for ( u=0; u=Norig) twidx-=Norig; + C_MUL(t,scratch[q] , twiddles[twidx] ); + C_ADDTO( Fout[ k ] ,t); + } + k += m; + } + } + KISS_FFT_TMP_FREE(scratch); +} + +static +void kf_work( + kiss_fft_cpx * Fout, + const kiss_fft_cpx * f, + const size_t fstride, + int in_stride, + int * factors, + const kiss_fft_cfg st + ) +{ + kiss_fft_cpx * Fout_beg=Fout; + const int p=*factors++; /* the radix */ + const int m=*factors++; /* stage's fft length/p */ + const kiss_fft_cpx * Fout_end = Fout + p*m; + +#ifdef _OPENMP + // use openmp extensions at the + // top-level (not recursive) + if (fstride==1 && p<=5) + { + int k; + + // execute the p different work units in different threads +# pragma omp parallel for + for (k=0;kr = f->r; + Fout->i = f->i; + f += fstride*in_stride; + }while(++Fout != Fout_end ); + }else{ + do{ + // recursive call: + // DFT of size m*p performed by doing + // p instances of smaller DFTs of size m, + // each one takes a decimated version of the input + kf_work( Fout , f, fstride*p, in_stride, factors,st); + f += fstride*in_stride; + }while( (Fout += m) != Fout_end ); + } + + Fout=Fout_beg; + + // recombine the p smaller DFTs + switch (p) { + case 2: kf_bfly2(Fout,fstride,st,m); break; + case 3: kf_bfly3(Fout,fstride,st,m); break; + case 4: kf_bfly4(Fout,fstride,st,m); break; + case 5: kf_bfly5(Fout,fstride,st,m); break; + default: kf_bfly_generic(Fout,fstride,st,m,p); break; + } +} + +/* facbuf is populated by p1,m1,p2,m2, ... + where + p[i] * m[i] = m[i-1] + m0 = n */ +static +void kf_factor(int n,int * facbuf) +{ + int p=4; + double floor_sqrt; + floor_sqrt = floor( sqrt((double)n) ); + + /*factor out powers of 4, powers of 2, then any remaining primes */ + do { + while (n % p) { + switch (p) { + case 4: p = 2; break; + case 2: p = 3; break; + default: p += 2; break; + } + if (p > floor_sqrt) + p = n; /* no more factors, skip to end */ + } + n /= p; + *facbuf++ = p; + *facbuf++ = n; + } while (n > 1); +} + +/* + * + * User-callable function to allocate all necessary storage space for the fft. + * + * The return value is a contiguous block of memory, allocated with malloc. As such, + * It can be freed with free(), rather than a kiss_fft-specific function. + * */ +kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem ) +{ + kiss_fft_cfg st=NULL; + size_t memneeded = sizeof(struct kiss_fft_state) + + sizeof(kiss_fft_cpx)*(nfft-1); /* twiddle factors*/ + + if ( lenmem==NULL ) { + st = ( kiss_fft_cfg)KISS_FFT_MALLOC( memneeded ); + }else{ + if (mem != NULL && *lenmem >= memneeded) + st = (kiss_fft_cfg)mem; + *lenmem = memneeded; + } + if (st) { + int i; + st->nfft=nfft; + st->inverse = inverse_fft; + + for (i=0;iinverse) + phase *= -1; + kf_cexp(st->twiddles+i, phase ); + } + + kf_factor(nfft,st->factors); + } + return st; +} + + +void kiss_fft_stride(kiss_fft_cfg st,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int in_stride) +{ + if (fin == fout) { + //NOTE: this is not really an in-place FFT algorithm. + //It just performs an out-of-place FFT into a temp buffer + kiss_fft_cpx * tmpbuf = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC( sizeof(kiss_fft_cpx)*st->nfft); + kf_work(tmpbuf,fin,1,in_stride, st->factors,st); + memcpy(fout,tmpbuf,sizeof(kiss_fft_cpx)*st->nfft); + KISS_FFT_TMP_FREE(tmpbuf); + }else{ + kf_work( fout, fin, 1,in_stride, st->factors,st ); + } +} + +void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout) +{ + kiss_fft_stride(cfg,fin,fout,1); +} + + +void kiss_fft_cleanup(void) +{ + // nothing needed any more +} + +int kiss_fft_next_fast_size(int n) +{ + while(1) { + int m=n; + while ( (m%2) == 0 ) m/=2; + while ( (m%3) == 0 ) m/=3; + while ( (m%5) == 0 ) m/=5; + if (m<=1) + break; /* n is completely factorable by twos, threes, and fives */ + n++; + } + return n; +} diff --git a/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.h b/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.h new file mode 100644 index 0000000..64c50f4 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/ext/kissfft/kiss_fft.h @@ -0,0 +1,124 @@ +#ifndef KISS_FFT_H +#define KISS_FFT_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + ATTENTION! + If you would like a : + -- a utility that will handle the caching of fft objects + -- real-only (no imaginary time component ) FFT + -- a multi-dimensional FFT + -- a command-line utility to perform ffts + -- a command-line utility to perform fast-convolution filtering + + Then see kfc.h kiss_fftr.h kiss_fftnd.h fftutil.c kiss_fastfir.c + in the tools/ directory. +*/ + +#ifdef USE_SIMD +# include +# define kiss_fft_scalar __m128 +#define KISS_FFT_MALLOC(nbytes) _mm_malloc(nbytes,16) +#define KISS_FFT_FREE _mm_free +#else +#define KISS_FFT_MALLOC malloc +#define KISS_FFT_FREE free +#endif + + +#ifdef FIXED_POINT +#include +# if (FIXED_POINT == 32) +# define kiss_fft_scalar int32_t +# else +# define kiss_fft_scalar int16_t +# endif +#else +# ifndef kiss_fft_scalar +/* default is float */ +# define kiss_fft_scalar float +# endif +#endif + +typedef struct { + kiss_fft_scalar r; + kiss_fft_scalar i; +}kiss_fft_cpx; + +typedef struct kiss_fft_state* kiss_fft_cfg; + +/* + * kiss_fft_alloc + * + * Initialize a FFT (or IFFT) algorithm's cfg/state buffer. + * + * typical usage: kiss_fft_cfg mycfg=kiss_fft_alloc(1024,0,NULL,NULL); + * + * The return value from fft_alloc is a cfg buffer used internally + * by the fft routine or NULL. + * + * If lenmem is NULL, then kiss_fft_alloc will allocate a cfg buffer using malloc. + * The returned value should be free()d when done to avoid memory leaks. + * + * The state can be placed in a user supplied buffer 'mem': + * If lenmem is not NULL and mem is not NULL and *lenmem is large enough, + * then the function places the cfg in mem and the size used in *lenmem + * and returns mem. + * + * If lenmem is not NULL and ( mem is NULL or *lenmem is not large enough), + * then the function returns NULL and places the minimum cfg + * buffer size in *lenmem. + * */ + +kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem); + +/* + * kiss_fft(cfg,in_out_buf) + * + * Perform an FFT on a complex input buffer. + * for a forward FFT, + * fin should be f[0] , f[1] , ... ,f[nfft-1] + * fout will be F[0] , F[1] , ... ,F[nfft-1] + * Note that each element is complex and can be accessed like + f[k].r and f[k].i + * */ +void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout); + +/* + A more generic version of the above function. It reads its input from every Nth sample. + * */ +void kiss_fft_stride(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int fin_stride); + +/* If kiss_fft_alloc allocated a buffer, it is one contiguous + buffer and can be simply free()d when no longer needed*/ +#define kiss_fft_free free + +/* + Cleans up some memory that gets managed internally. Not necessary to call, but it might clean up + your compiler output to call this before you exit. +*/ +void kiss_fft_cleanup(void); + + +/* + * Returns the smallest integer k, such that k>=n and k has only "fast" factors (2,3,5) + */ +int kiss_fft_next_fast_size(int n); + +/* for real ffts, we need an even size */ +#define kiss_fftr_next_fast_size_real(n) \ + (kiss_fft_next_fast_size( ((n)+1)>>1)<<1) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.c b/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.c new file mode 100644 index 0000000..b8e238b --- /dev/null +++ b/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.c @@ -0,0 +1,159 @@ +/* +Copyright (c) 2003-2004, Mark Borgerding + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the author nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#include "kiss_fftr.h" +#include "_kiss_fft_guts.h" + +struct kiss_fftr_state{ + kiss_fft_cfg substate; + kiss_fft_cpx * tmpbuf; + kiss_fft_cpx * super_twiddles; +#ifdef USE_SIMD + void * pad; +#endif +}; + +kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem) +{ + int i; + kiss_fftr_cfg st = NULL; + size_t subsize, memneeded; + + if (nfft & 1) { + fprintf(stderr,"Real FFT optimization must be even.\n"); + return NULL; + } + nfft >>= 1; + + kiss_fft_alloc (nfft, inverse_fft, NULL, &subsize); + memneeded = sizeof(struct kiss_fftr_state) + subsize + sizeof(kiss_fft_cpx) * ( nfft * 3 / 2); + + if (lenmem == NULL) { + st = (kiss_fftr_cfg) KISS_FFT_MALLOC (memneeded); + } else { + if (*lenmem >= memneeded) + st = (kiss_fftr_cfg) mem; + *lenmem = memneeded; + } + if (!st) + return NULL; + + st->substate = (kiss_fft_cfg) (st + 1); /*just beyond kiss_fftr_state struct */ + st->tmpbuf = (kiss_fft_cpx *) (((char *) st->substate) + subsize); + st->super_twiddles = st->tmpbuf + nfft; + kiss_fft_alloc(nfft, inverse_fft, st->substate, &subsize); + + for (i = 0; i < nfft/2; ++i) { + double phase = + -3.14159265358979323846264338327 * ((double) (i+1) / nfft + .5); + if (inverse_fft) + phase *= -1; + kf_cexp (st->super_twiddles+i,phase); + } + return st; +} + +void kiss_fftr(kiss_fftr_cfg st,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata) +{ + /* input buffer timedata is stored row-wise */ + int k,ncfft; + kiss_fft_cpx fpnk,fpk,f1k,f2k,tw,tdc; + + if ( st->substate->inverse) { + fprintf(stderr,"kiss fft usage error: improper alloc\n"); + exit(1); + } + + ncfft = st->substate->nfft; + + /*perform the parallel fft of two real signals packed in real,imag*/ + kiss_fft( st->substate , (const kiss_fft_cpx*)timedata, st->tmpbuf ); + /* The real part of the DC element of the frequency spectrum in st->tmpbuf + * contains the sum of the even-numbered elements of the input time sequence + * The imag part is the sum of the odd-numbered elements + * + * The sum of tdc.r and tdc.i is the sum of the input time sequence. + * yielding DC of input time sequence + * The difference of tdc.r - tdc.i is the sum of the input (dot product) [1,-1,1,-1... + * yielding Nyquist bin of input time sequence + */ + + tdc.r = st->tmpbuf[0].r; + tdc.i = st->tmpbuf[0].i; + C_FIXDIV(tdc,2); + CHECK_OVERFLOW_OP(tdc.r ,+, tdc.i); + CHECK_OVERFLOW_OP(tdc.r ,-, tdc.i); + freqdata[0].r = tdc.r + tdc.i; + freqdata[ncfft].r = tdc.r - tdc.i; +#ifdef USE_SIMD + freqdata[ncfft].i = freqdata[0].i = _mm_set1_ps(0); +#else + freqdata[ncfft].i = freqdata[0].i = 0; +#endif + + for ( k=1;k <= ncfft/2 ; ++k ) { + fpk = st->tmpbuf[k]; + fpnk.r = st->tmpbuf[ncfft-k].r; + fpnk.i = - st->tmpbuf[ncfft-k].i; + C_FIXDIV(fpk,2); + C_FIXDIV(fpnk,2); + + C_ADD( f1k, fpk , fpnk ); + C_SUB( f2k, fpk , fpnk ); + C_MUL( tw , f2k , st->super_twiddles[k-1]); + + freqdata[k].r = HALF_OF(f1k.r + tw.r); + freqdata[k].i = HALF_OF(f1k.i + tw.i); + freqdata[ncfft-k].r = HALF_OF(f1k.r - tw.r); + freqdata[ncfft-k].i = HALF_OF(tw.i - f1k.i); + } +} + +void kiss_fftri(kiss_fftr_cfg st,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata) +{ + /* input buffer timedata is stored row-wise */ + int k, ncfft; + + if (st->substate->inverse == 0) { + fprintf (stderr, "kiss fft usage error: improper alloc\n"); + exit (1); + } + + ncfft = st->substate->nfft; + + st->tmpbuf[0].r = freqdata[0].r + freqdata[ncfft].r; + st->tmpbuf[0].i = freqdata[0].r - freqdata[ncfft].r; + C_FIXDIV(st->tmpbuf[0],2); + + for (k = 1; k <= ncfft / 2; ++k) { + kiss_fft_cpx fk, fnkc, fek, fok, tmp; + fk = freqdata[k]; + fnkc.r = freqdata[ncfft - k].r; + fnkc.i = -freqdata[ncfft - k].i; + C_FIXDIV( fk , 2 ); + C_FIXDIV( fnkc , 2 ); + + C_ADD (fek, fk, fnkc); + C_SUB (tmp, fk, fnkc); + C_MUL (fok, tmp, st->super_twiddles[k-1]); + C_ADD (st->tmpbuf[k], fek, fok); + C_SUB (st->tmpbuf[ncfft - k], fek, fok); +#ifdef USE_SIMD + st->tmpbuf[ncfft - k].i *= _mm_set1_ps(-1.0); +#else + st->tmpbuf[ncfft - k].i *= -1; +#endif + } + kiss_fft (st->substate, st->tmpbuf, (kiss_fft_cpx *) timedata); +} diff --git a/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.h b/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.h new file mode 100644 index 0000000..72e5a57 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/ext/kissfft/tools/kiss_fftr.h @@ -0,0 +1,46 @@ +#ifndef KISS_FTR_H +#define KISS_FTR_H + +#include "kiss_fft.h" +#ifdef __cplusplus +extern "C" { +#endif + + +/* + + Real optimized version can save about 45% cpu time vs. complex fft of a real seq. + + + + */ + +typedef struct kiss_fftr_state *kiss_fftr_cfg; + + +kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem, size_t * lenmem); +/* + nfft must be even + + If you don't care to allocate space, use mem = lenmem = NULL +*/ + + +void kiss_fftr(kiss_fftr_cfg cfg,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata); +/* + input timedata has nfft scalar points + output freqdata has nfft/2+1 complex points +*/ + +void kiss_fftri(kiss_fftr_cfg cfg,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata); +/* + input freqdata has nfft/2+1 complex points + output timedata has nfft scalar points +*/ + +#define kiss_fftr_free free + +#ifdef __cplusplus +} +#endif +#endif diff --git a/crates/dj/vendor/qm-dsp/maths/Correlation.cpp b/crates/dj/vendor/qm-dsp/maths/Correlation.cpp new file mode 100644 index 0000000..6628e5f --- /dev/null +++ b/crates/dj/vendor/qm-dsp/maths/Correlation.cpp @@ -0,0 +1,56 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file 2005-2006 Christian Landone. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "Correlation.h" + +#include "MathAliases.h" + +////////////////////////////////////////////////////////////////////// +// Construction/Destruction +////////////////////////////////////////////////////////////////////// + +Correlation::Correlation() +{ + +} + +Correlation::~Correlation() +{ + +} + +void Correlation::doAutoUnBiased(double *src, double *dst, int length) +{ + double tmp = 0.0; + double outVal = 0.0; + + int i, j; + + for (i = 0; i < length; i++) { + for (j = i; j < length; j++) { + tmp += src[ j-i ] * src[ j ]; + } + + outVal = tmp / ( length - i ); + + if (outVal <= 0) { + dst[ i ] = EPS; + } else { + dst[ i ] = outVal; + } + + tmp = 0.0; + } +} diff --git a/crates/dj/vendor/qm-dsp/maths/Correlation.h b/crates/dj/vendor/qm-dsp/maths/Correlation.h new file mode 100644 index 0000000..b146241 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/maths/Correlation.h @@ -0,0 +1,27 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file 2005-2006 Christian Landone. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef QM_DSP_CORRELATION_H +#define QM_DSP_CORRELATION_H + +class Correlation +{ +public: + Correlation(); + virtual ~Correlation(); + + void doAutoUnBiased( double* src, double* dst, int length ); +}; + +#endif diff --git a/crates/dj/vendor/qm-dsp/maths/MathUtilities.cpp b/crates/dj/vendor/qm-dsp/maths/MathUtilities.cpp new file mode 100644 index 0000000..a8ada2e --- /dev/null +++ b/crates/dj/vendor/qm-dsp/maths/MathUtilities.cpp @@ -0,0 +1,415 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file 2005-2006 Christian Landone. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#include "MathUtilities.h" + +#include +#include +#include +#include + +using namespace std; + +double MathUtilities::mod(double x, double y) +{ + double a = floor( x / y ); + + double b = x - ( y * a ); + return b; +} + +double MathUtilities::princarg(double ang) +{ + double ValOut; + + ValOut = mod( ang + M_PI, -2 * M_PI ) + M_PI; + + return ValOut; +} + +void MathUtilities::getAlphaNorm(const double *data, int len, int alpha, double* ANorm) +{ + int i; + double temp = 0.0; + double a=0.0; + + for( i = 0; i < len; i++) { + temp = data[ i ]; + a += ::pow( fabs(temp), double(alpha) ); + } + a /= ( double )len; + a = ::pow( a, ( 1.0 / (double) alpha ) ); + + *ANorm = a; +} + +double MathUtilities::getAlphaNorm( const vector &data, int alpha ) +{ + int i; + int len = data.size(); + double temp = 0.0; + double a=0.0; + + for( i = 0; i < len; i++) { + temp = data[ i ]; + a += ::pow( fabs(temp), double(alpha) ); + } + a /= ( double )len; + a = ::pow( a, ( 1.0 / (double) alpha ) ); + + return a; +} + +double MathUtilities::round(double x) +{ + if (x < 0) { + return -floor(-x + 0.5); + } else { + return floor(x + 0.5); + } +} + +double MathUtilities::median(const double *src, int len) +{ + if (len == 0) return 0; + + vector scratch; + for (int i = 0; i < len; ++i) scratch.push_back(src[i]); + sort(scratch.begin(), scratch.end()); + + int middle = len/2; + if (len % 2 == 0) { + return (scratch[middle] + scratch[middle - 1]) / 2; + } else { + return scratch[middle]; + } +} + +double MathUtilities::sum(const double *src, int len) +{ + int i ; + double retVal =0.0; + + for( i = 0; i < len; i++) { + retVal += src[ i ]; + } + + return retVal; +} + +double MathUtilities::mean(const double *src, int len) +{ + double retVal =0.0; + + if (len == 0) return 0; + + double s = sum( src, len ); + + retVal = s / (double)len; + + return retVal; +} + +double MathUtilities::mean(const vector &src, + int start, + int count) +{ + double sum = 0.; + + if (count == 0) return 0; + + for (int i = 0; i < (int)count; ++i) { + sum += src[start + i]; + } + + return sum / count; +} + +void MathUtilities::getFrameMinMax(const double *data, int len, double *min, double *max) +{ + int i; + double temp = 0.0; + + if (len == 0) { + *min = *max = 0; + return; + } + + *min = data[0]; + *max = data[0]; + + for( i = 0; i < len; i++) { + temp = data[ i ]; + + if( temp < *min ) { + *min = temp ; + } + if( temp > *max ) { + *max = temp ; + } + } +} + +int MathUtilities::getMax( double* pData, int Length, double* pMax ) +{ + int index = 0; + int i; + double temp = 0.0; + + double max = pData[0]; + + for( i = 0; i < Length; i++) { + temp = pData[ i ]; + + if( temp > max ) { + max = temp ; + index = i; + } + } + + if (pMax) *pMax = max; + + + return index; +} + +int MathUtilities::getMax( const vector & data, double* pMax ) +{ + int index = 0; + int i; + double temp = 0.0; + + double max = data[0]; + + for( i = 0; i < int(data.size()); i++) { + + temp = data[ i ]; + + if( temp > max ) { + max = temp ; + index = i; + } + } + + if (pMax) *pMax = max; + + + return index; +} + +void MathUtilities::circShift( double* pData, int length, int shift) +{ + shift = shift % length; + double temp; + int i,n; + + for( i = 0; i < shift; i++) { + + temp=*(pData + length - 1); + + for( n = length-2; n >= 0; n--) { + *(pData+n+1)=*(pData+n); + } + + *pData = temp; + } +} + +int MathUtilities::compareInt (const void * a, const void * b) +{ + return ( *(int*)a - *(int*)b ); +} + +void MathUtilities::normalise(double *data, int length, NormaliseType type) +{ + switch (type) { + + case NormaliseNone: return; + + case NormaliseUnitSum: + { + double sum = 0.0; + for (int i = 0; i < length; ++i) { + sum += data[i]; + } + if (sum != 0.0) { + for (int i = 0; i < length; ++i) { + data[i] /= sum; + } + } + } + break; + + case NormaliseUnitMax: + { + double max = 0.0; + for (int i = 0; i < length; ++i) { + if (fabs(data[i]) > max) { + max = fabs(data[i]); + } + } + if (max != 0.0) { + for (int i = 0; i < length; ++i) { + data[i] /= max; + } + } + } + break; + + } +} + +void MathUtilities::normalise(vector &data, NormaliseType type) +{ + switch (type) { + + case NormaliseNone: return; + + case NormaliseUnitSum: + { + double sum = 0.0; + for (int i = 0; i < (int)data.size(); ++i) sum += data[i]; + if (sum != 0.0) { + for (int i = 0; i < (int)data.size(); ++i) data[i] /= sum; + } + } + break; + + case NormaliseUnitMax: + { + double max = 0.0; + for (int i = 0; i < (int)data.size(); ++i) { + if (fabs(data[i]) > max) max = fabs(data[i]); + } + if (max != 0.0) { + for (int i = 0; i < (int)data.size(); ++i) data[i] /= max; + } + } + break; + + } +} + +double MathUtilities::getLpNorm(const vector &data, int p) +{ + double tot = 0.0; + for (int i = 0; i < int(data.size()); ++i) { + tot += abs(pow(data[i], p)); + } + return pow(tot, 1.0 / p); +} + +vector MathUtilities::normaliseLp(const vector &data, + int p, + double threshold) +{ + int n = int(data.size()); + if (n == 0 || p == 0) return data; + double norm = getLpNorm(data, p); + if (norm < threshold) { + return vector(n, 1.0 / pow(n, 1.0 / p)); // unit vector + } + vector out(n); + for (int i = 0; i < n; ++i) { + out[i] = data[i] / norm; + } + return out; +} + +void MathUtilities::adaptiveThreshold(vector &data) +{ + int sz = int(data.size()); + if (sz == 0) return; + + vector smoothed(sz); + + int p_pre = 8; + int p_post = 7; + + for (int i = 0; i < sz; ++i) { + + int first = max(0, i - p_pre); + int last = min(sz - 1, i + p_post); + + smoothed[i] = mean(data, first, last - first + 1); + } + + for (int i = 0; i < sz; i++) { + data[i] -= smoothed[i]; + if (data[i] < 0.0) data[i] = 0.0; + } +} + +bool +MathUtilities::isPowerOfTwo(int x) +{ + if (x < 1) return false; + if (x & (x-1)) return false; + return true; +} + +int +MathUtilities::nextPowerOfTwo(int x) +{ + if (isPowerOfTwo(x)) return x; + if (x < 1) return 1; + int n = 1; + while (x) { x >>= 1; n <<= 1; } + return n; +} + +int +MathUtilities::previousPowerOfTwo(int x) +{ + if (isPowerOfTwo(x)) return x; + if (x < 1) return 1; + int n = 1; + x >>= 1; + while (x) { x >>= 1; n <<= 1; } + return n; +} + +int +MathUtilities::nearestPowerOfTwo(int x) +{ + if (isPowerOfTwo(x)) return x; + int n0 = previousPowerOfTwo(x); + int n1 = nextPowerOfTwo(x); + if (x - n0 < n1 - x) return n0; + else return n1; +} + +double +MathUtilities::factorial(int x) +{ + if (x < 0) return 0; + double f = 1; + for (int i = 1; i <= x; ++i) { + f = f * i; + } + return f; +} + +int +MathUtilities::gcd(int a, int b) +{ + int c = a % b; + if (c == 0) { + return b; + } else { + return gcd(b, c); + } +} + diff --git a/crates/dj/vendor/qm-dsp/maths/MathUtilities.h b/crates/dj/vendor/qm-dsp/maths/MathUtilities.h new file mode 100644 index 0000000..415d931 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/maths/MathUtilities.h @@ -0,0 +1,168 @@ +/* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ + +/* + QM DSP Library + + Centre for Digital Music, Queen Mary, University of London. + This file 2005-2006 Christian Landone. + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation; either version 2 of the + License, or (at your option) any later version. See the file + COPYING included with this distribution for more information. +*/ + +#ifndef MATHUTILITIES_H +#define MATHUTILITIES_H + +#include + +#include "nan-inf.h" + +/** + * Static helper functions for simple mathematical calculations. + */ +class MathUtilities +{ +public: + /** + * Round x to the nearest integer. + */ + static double round( double x ); + + /** + * Return through min and max pointers the highest and lowest + * values in the given array of the given length. + */ + static void getFrameMinMax( const double* data, int len, + double* min, double* max ); + + /** + * Return the mean of the given array of the given length. + */ + static double mean( const double* src, int len ); + + /** + * Return the mean of the subset of the given vector identified by + * start and count. + */ + static double mean( const std::vector &data, + int start, int count ); + + /** + * Return the sum of the values in the given array of the given + * length. + */ + static double sum( const double* src, int len ); + + /** + * Return the median of the values in the given array of the given + * length. If the array is even in length, the returned value will + * be half-way between the two values adjacent to median. + */ + static double median( const double* src, int len ); + + /** + * The principle argument function. Map the phase angle ang into + * the range [-pi,pi). + */ + static double princarg( double ang ); + + /** + * Floating-point division modulus: return x % y. + */ + static double mod( double x, double y); + + /** + * The alpha norm is the alpha'th root of the mean alpha'th power + * magnitude. For example if alpha = 2 this corresponds to the RMS + * of the input data, and when alpha = 1 this is the mean + * magnitude. + */ + static void getAlphaNorm(const double *data, int len, int alpha, double* ANorm); + + /** + * The alpha norm is the alpha'th root of the mean alpha'th power + * magnitude. For example if alpha = 2 this corresponds to the RMS + * of the input data, and when alpha = 1 this is the mean + * magnitude. + */ + static double getAlphaNorm(const std::vector &data, int alpha ); + + enum NormaliseType { + NormaliseNone, + NormaliseUnitSum, + NormaliseUnitMax + }; + + static void normalise(double *data, int length, + NormaliseType n = NormaliseUnitMax); + + static void normalise(std::vector &data, + NormaliseType n = NormaliseUnitMax); + + /** + * Calculate the L^p norm of a vector. Equivalent to MATLAB's + * norm(data, p). + */ + static double getLpNorm(const std::vector &data, + int p); + + /** + * Normalise a vector by dividing through by its L^p norm. If the + * norm is below the given threshold, the unit vector for that + * norm is returned. p may be 0, in which case no normalisation + * happens and the data is returned unchanged. + */ + static std::vector normaliseLp(const std::vector &data, + int p, + double threshold = 1e-6); + + /** + * Threshold the input/output vector data against a moving-mean + * average filter. + */ + static void adaptiveThreshold(std::vector &data); + + static void circShift( double* data, int length, int shift); + + static int getMax( double* data, int length, double* max = 0 ); + static int getMax( const std::vector &data, double* max = 0 ); + static int compareInt(const void * a, const void * b); + + /** + * Return true if x is 2^n for some integer n >= 0. + */ + static bool isPowerOfTwo(int x); + + /** + * Return the next higher integer power of two from x, e.g. 1300 + * -> 2048, 2048 -> 2048. + */ + static int nextPowerOfTwo(int x); + + /** + * Return the next lower integer power of two from x, e.g. 1300 -> + * 1024, 2048 -> 2048. + */ + static int previousPowerOfTwo(int x); + + /** + * Return the nearest integer power of two to x, e.g. 1300 -> 1024, + * 12 -> 16 (not 8; if two are equidistant, the higher is returned). + */ + static int nearestPowerOfTwo(int x); + + /** + * Return x! + */ + static double factorial(int x); // returns double in case it is large + + /** + * Return the greatest common divisor of natural numbers a and b. + */ + static int gcd(int a, int b); +}; + +#endif diff --git a/crates/dj/vendor/qm-dsp/maths/nan-inf.h b/crates/dj/vendor/qm-dsp/maths/nan-inf.h new file mode 100644 index 0000000..1d12047 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/maths/nan-inf.h @@ -0,0 +1,13 @@ + +#ifndef QM_DSP_NAN_INF_H +#define QM_DSP_NAN_INF_H + +#define ISNAN(x) (sizeof(x) == sizeof(double) ? ISNANd(x) : ISNANf(x)) +static inline int ISNANf(float x) { return x != x; } +static inline int ISNANd(double x) { return x != x; } + +#define ISINF(x) (sizeof(x) == sizeof(double) ? ISINFd(x) : ISINFf(x)) +static inline int ISINFf(float x) { return !ISNANf(x) && ISNANf(x - x); } +static inline int ISINFd(double x) { return !ISNANd(x) && ISNANd(x - x); } + +#endif diff --git a/crates/dj/vendor/qm-dsp/wrapper.cpp b/crates/dj/vendor/qm-dsp/wrapper.cpp new file mode 100644 index 0000000..4094d67 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/wrapper.cpp @@ -0,0 +1,119 @@ +/* C wrapper implementation for QM-DSP TempoTrackV2 */ + +#include "wrapper.h" +#include "dsp/tempotracking/TempoTrackV2.h" + +#include + +struct QmTempoTracker { + TempoTrackV2* impl; + float sample_rate; + int df_increment; +}; + +extern "C" { + +QmTempoTracker* qm_tempo_new(float sample_rate, int df_increment) { + QmTempoTracker* tracker = new (std::nothrow) QmTempoTracker; + if (!tracker) { + return nullptr; + } + + tracker->impl = new (std::nothrow) TempoTrackV2(sample_rate, df_increment); + if (!tracker->impl) { + delete tracker; + return nullptr; + } + + tracker->sample_rate = sample_rate; + tracker->df_increment = df_increment; + return tracker; +} + +void qm_tempo_free(QmTempoTracker* tracker) { + if (tracker) { + delete tracker->impl; + delete tracker; + } +} + +int qm_tempo_calculate_beat_period( + QmTempoTracker* tracker, + const double* df, int df_len, + double* beat_periods, double* tempi, int* out_len +) { + if (!tracker || !tracker->impl || !df || !beat_periods || !tempi || !out_len) { + return -1; + } + + if (df_len <= 0) { + *out_len = 0; + return 0; + } + + // Convert input to std::vector + std::vector df_vec(df, df + df_len); + + // IMPORTANT: beat_period must be pre-sized to df_len as the C++ code + // writes directly into it by index (see TempoTrackV2.cpp line 340) + std::vector bp_vec(df_len, 0.0); + + // tempi is appended to, so should be empty + std::vector tempi_vec; + + // Call TempoTrackV2 + tracker->impl->calculateBeatPeriod(df_vec, bp_vec, tempi_vec); + + // Copy results to output arrays + int len = static_cast(tempi_vec.size()); + if (len > df_len) { + len = df_len; // Safety: don't exceed provided buffer + } + + for (int i = 0; i < len; i++) { + beat_periods[i] = bp_vec[i]; + tempi[i] = tempi_vec[i]; + } + + *out_len = len; + return 0; +} + +int qm_tempo_calculate_beats( + QmTempoTracker* tracker, + const double* df, int df_len, + const double* beat_periods, int bp_len, + double* beats, int* beats_len +) { + if (!tracker || !tracker->impl || !df || !beat_periods || !beats || !beats_len) { + return -1; + } + + if (df_len <= 0 || bp_len <= 0) { + *beats_len = 0; + return 0; + } + + // Convert inputs to std::vector + std::vector df_vec(df, df + df_len); + std::vector bp_vec(beat_periods, beat_periods + bp_len); + std::vector beats_vec; + + // Call TempoTrackV2 + tracker->impl->calculateBeats(df_vec, bp_vec, beats_vec); + + // Copy results to output array + int len = static_cast(beats_vec.size()); + if (len > df_len) { + len = df_len; // Safety: don't exceed provided buffer + } + + for (int i = 0; i < len; i++) { + beats[i] = beats_vec[i]; + } + + *beats_len = len; + return 0; +} + +} // extern "C" diff --git a/crates/dj/vendor/qm-dsp/wrapper.h b/crates/dj/vendor/qm-dsp/wrapper.h new file mode 100644 index 0000000..0e74cc0 --- /dev/null +++ b/crates/dj/vendor/qm-dsp/wrapper.h @@ -0,0 +1,69 @@ +/* C wrapper for QM-DSP TempoTrackV2 */ + +#ifndef QM_DSP_WRAPPER_H +#define QM_DSP_WRAPPER_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Opaque handle to TempoTrackV2 instance */ +typedef struct QmTempoTracker QmTempoTracker; + +/** + * Create a new TempoTrackV2 instance. + * + * @param sample_rate Audio sample rate (e.g., 44100.0) + * @param df_increment Detection function frame increment (e.g., 512) + * @return Pointer to new tracker, or NULL on failure + */ +QmTempoTracker* qm_tempo_new(float sample_rate, int df_increment); + +/** + * Free a TempoTrackV2 instance. + * + * @param tracker Pointer to tracker (may be NULL) + */ +void qm_tempo_free(QmTempoTracker* tracker); + +/** + * Calculate beat periods and tempi from a detection function. + * + * @param tracker Pointer to tracker + * @param df Detection function values + * @param df_len Length of detection function array + * @param beat_periods Output array for beat periods (must be at least df_len elements) + * @param tempi Output array for tempi in BPM (must be at least df_len elements) + * @param out_len Pointer to receive actual output length + * @return 0 on success, -1 on failure + */ +int qm_tempo_calculate_beat_period( + QmTempoTracker* tracker, + const double* df, int df_len, + double* beat_periods, double* tempi, int* out_len +); + +/** + * Calculate beat positions from detection function and beat periods. + * + * @param tracker Pointer to tracker + * @param df Detection function values + * @param df_len Length of detection function array + * @param beat_periods Beat periods from qm_tempo_calculate_beat_period + * @param bp_len Length of beat periods array + * @param beats Output array for beat positions (must be at least df_len elements) + * @param beats_len Pointer to receive actual number of beats + * @return 0 on success, -1 on failure + */ +int qm_tempo_calculate_beats( + QmTempoTracker* tracker, + const double* df, int df_len, + const double* beat_periods, int bp_len, + double* beats, int* beats_len +); + +#ifdef __cplusplus +} +#endif + +#endif /* QM_DSP_WRAPPER_H */ diff --git a/crates/halo/Cargo.toml b/crates/halo/Cargo.toml index 5402a11..600b6ad 100644 --- a/crates/halo/Cargo.toml +++ b/crates/halo/Cargo.toml @@ -17,6 +17,8 @@ edition = "2021" [dependencies] halo-core = { path = "../core" } +halo-dj = { path = "../dj" } +halo-push2 = { path = "../push2" } halo-ui = { path = "../ui" } halo-fixtures = { path = "../fixtures" } rusty_link = "0.4.6" diff --git a/crates/halo/src/main.rs b/crates/halo/src/main.rs index 4e8fa84..a402c48 100644 --- a/crates/halo/src/main.rs +++ b/crates/halo/src/main.rs @@ -197,9 +197,21 @@ async fn main() -> anyhow::Result<()> { }); // Create the async console with loaded settings - let console = + let mut console = LightingConsole::new_with_settings(80., network_config.clone(), settings.clone()).unwrap(); + // Register the DJ module + console.register_module(Box::new(halo_dj::DjModule::new())); + + // Register the Push 2 module if enabled + if settings.push2_enabled { + console.register_module(Box::new(halo_push2::Push2Module::new())); + println!("Push 2 support: enabled"); + println!(" (USB display diagnostics will appear during initialization)"); + } else { + println!("Push 2 support: disabled (set push2_enabled: true in config.json to enable)"); + } + // // Blue Strobe Fast // console.add_midi_override( // 76, @@ -254,10 +266,12 @@ async fn main() -> anyhow::Result<()> { // Spawn the console task with channel communication let console_task = tokio::spawn(async move { + eprintln!("DEBUG: Console task started"); // Run the console with channels if let Err(e) = console.run_with_channels(command_rx, event_tx).await { println!("Console error: {}", e); } + eprintln!("DEBUG: Console task ended"); }); // Store the show file path for later loading after UI starts diff --git a/crates/push2/Cargo.toml b/crates/push2/Cargo.toml new file mode 100644 index 0000000..7400b9d --- /dev/null +++ b/crates/push2/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "halo-push2" +version = "0.1.0" +edition = "2021" +description = "Ableton Push 2 integration for Halo lighting console" +license = "MIT" + +[dependencies] +# Internal crates +halo-core = { path = "../core" } +halo-dj = { path = "../dj" } + +# Async runtime +tokio = { version = "1.43", features = ["full"] } +async-trait = "0.1" + +# USB access for display +rusb = "0.9" + +# MIDI I/O +midir = "0.10" + +# Logging +tracing = "0.1" + +# Error handling +thiserror = "2.0" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/crates/push2/src/display/driver.rs b/crates/push2/src/display/driver.rs new file mode 100644 index 0000000..cedff23 --- /dev/null +++ b/crates/push2/src/display/driver.rs @@ -0,0 +1,151 @@ +//! USB display driver for Ableton Push 2. +//! +//! The Push 2 display uses a USB bulk transfer protocol: +//! - Vendor ID: 0x2982 +//! - Product ID: 0x1967 +//! - Frame format: BGR565, 960x160 pixels +//! - XOR mask: 0xFFE7F3E7 applied to frame data +//! - Transfer: 640 buffers of 512 bytes each + +use rusb::{Context, DeviceHandle, UsbContext}; +use thiserror::Error; + +use super::FrameBuffer; + +/// Push 2 USB identifiers +const PUSH2_VENDOR_ID: u16 = 0x2982; +const PUSH2_PRODUCT_ID: u16 = 0x1967; + +/// USB endpoint for display data +const DISPLAY_ENDPOINT: u8 = 0x01; + +/// Frame header sent before each frame +const FRAME_HEADER: [u8; 16] = [ + 0xFF, 0xCC, 0xAA, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +/// XOR mask for frame data (applied as u32 words) +const XOR_MASK: [u8; 4] = [0xE7, 0xF3, 0xE7, 0xFF]; + +/// USB transfer timeout in milliseconds +const USB_TIMEOUT_MS: u64 = 1000; + +/// Errors that can occur with the Push 2 display. +#[derive(Debug, Error)] +pub enum Push2DisplayError { + #[error("Push 2 device not found")] + DeviceNotFound, + + #[error("USB error: {0}")] + UsbError(#[from] rusb::Error), + + #[error("Failed to claim interface")] + InterfaceClaim, + + #[error("Frame transfer failed")] + TransferFailed, +} + +/// Push 2 USB display driver. +pub struct Push2Display { + handle: DeviceHandle, + interface_claimed: bool, +} + +impl Push2Display { + /// List all USB devices for debugging purposes. + /// Returns a vector of "vendor_id:product_id" strings. + pub fn list_usb_devices() -> Vec { + let context = match Context::new() { + Ok(c) => c, + Err(e) => return vec![format!("Failed to create USB context: {e}")], + }; + + match context.devices() { + Ok(devices) => devices + .iter() + .filter_map(|d| { + d.device_descriptor().ok().map(|desc| { + let vid = desc.vendor_id(); + let pid = desc.product_id(); + let is_push2 = vid == PUSH2_VENDOR_ID && pid == PUSH2_PRODUCT_ID; + if is_push2 { + format!("{vid:04x}:{pid:04x} (Push 2)") + } else { + format!("{vid:04x}:{pid:04x}") + } + }) + }) + .collect(), + Err(e) => vec![format!("Failed to enumerate devices: {e}")], + } + } + + /// Create a new Push2Display by connecting to the device. + pub fn new() -> Result { + let context = Context::new()?; + + // Find Push 2 device + let device = context + .devices()? + .iter() + .find(|d| { + d.device_descriptor().map_or(false, |desc| { + desc.vendor_id() == PUSH2_VENDOR_ID && desc.product_id() == PUSH2_PRODUCT_ID + }) + }) + .ok_or(Push2DisplayError::DeviceNotFound)?; + + // Open device + let mut handle = device.open()?; + + // Claim interface + let interface_claimed = handle.claim_interface(0).is_ok(); + if !interface_claimed { + tracing::warn!("Could not claim USB interface - display may not work"); + } + + Ok(Self { + handle, + interface_claimed, + }) + } + + /// Send a frame to the display. + pub fn send_frame(&mut self, frame_buffer: &FrameBuffer) -> Result<(), Push2DisplayError> { + if !self.interface_claimed { + return Err(Push2DisplayError::InterfaceClaim); + } + + let timeout = std::time::Duration::from_millis(USB_TIMEOUT_MS); + + // Send frame header + self.handle + .write_bulk(DISPLAY_ENDPOINT, &FRAME_HEADER, timeout)?; + + // Get encoded frame data + let frame_data = frame_buffer.to_usb_frame(); + + // Send frame data in 512-byte chunks + for chunk in frame_data.chunks(512) { + self.handle.write_bulk(DISPLAY_ENDPOINT, chunk, timeout)?; + } + + Ok(()) + } + + /// Apply XOR mask to frame data (in-place). + pub fn apply_xor_mask(data: &mut [u8]) { + for (i, byte) in data.iter_mut().enumerate() { + *byte ^= XOR_MASK[i % 4]; + } + } +} + +impl Drop for Push2Display { + fn drop(&mut self) { + if self.interface_claimed { + let _ = self.handle.release_interface(0); + } + } +} diff --git a/crates/push2/src/display/frame_buffer.rs b/crates/push2/src/display/frame_buffer.rs new file mode 100644 index 0000000..72bb6c8 --- /dev/null +++ b/crates/push2/src/display/frame_buffer.rs @@ -0,0 +1,286 @@ +//! Frame buffer for Push 2 display. +//! +//! The Push 2 display is 960x160 pixels using BGR565 format (16-bit color). + +/// Display width in pixels +pub const DISPLAY_WIDTH: usize = 960; + +/// Display height in pixels +pub const DISPLAY_HEIGHT: usize = 160; + +/// Total frame size in bytes (960 * 160 * 2 bytes per pixel) +pub const FRAME_SIZE: usize = DISPLAY_WIDTH * DISPLAY_HEIGHT * 2; + +/// Line stride for USB transfer (1024 pixels per line, padded from 960) +const LINE_STRIDE: usize = 2048; // 1024 pixels * 2 bytes + +/// XOR mask for USB transfer +const XOR_MASK: [u8; 4] = [0xE7, 0xF3, 0xE7, 0xFF]; + +/// Frame buffer for Push 2 display. +/// +/// Stores pixels in BGR565 format (16-bit color): +/// - Bits 0-4: Blue (5 bits) +/// - Bits 5-10: Green (6 bits) +/// - Bits 11-15: Red (5 bits) +pub struct FrameBuffer { + /// Raw pixel data in BGR565 format + pixels: Vec, +} + +impl FrameBuffer { + /// Create a new empty frame buffer (black). + pub fn new() -> Self { + Self { + pixels: vec![0; DISPLAY_WIDTH * DISPLAY_HEIGHT], + } + } + + /// Clear the frame buffer to black. + pub fn clear(&mut self) { + self.pixels.fill(0); + } + + /// Fill the entire frame buffer with a color. + pub fn fill(&mut self, color: u16) { + self.pixels.fill(color); + } + + /// Set a single pixel. + #[inline] + pub fn set_pixel(&mut self, x: usize, y: usize, color: u16) { + if x < DISPLAY_WIDTH && y < DISPLAY_HEIGHT { + self.pixels[y * DISPLAY_WIDTH + x] = color; + } + } + + /// Get a pixel value. + #[inline] + pub fn get_pixel(&self, x: usize, y: usize) -> u16 { + if x < DISPLAY_WIDTH && y < DISPLAY_HEIGHT { + self.pixels[y * DISPLAY_WIDTH + x] + } else { + 0 + } + } + + /// Convert RGB888 to BGR565. + #[inline] + pub fn rgb_to_bgr565(r: u8, g: u8, b: u8) -> u16 { + let r5 = (r >> 3) as u16; + let g6 = (g >> 2) as u16; + let b5 = (b >> 3) as u16; + (r5 << 11) | (g6 << 5) | b5 + } + + /// Set a pixel using RGB888 values. + #[inline] + pub fn set_pixel_rgb(&mut self, x: usize, y: usize, r: u8, g: u8, b: u8) { + self.set_pixel(x, y, Self::rgb_to_bgr565(r, g, b)); + } + + /// Draw a filled rectangle. + pub fn draw_rect(&mut self, x: usize, y: usize, w: usize, h: usize, color: u16) { + for py in y..(y + h).min(DISPLAY_HEIGHT) { + for px in x..(x + w).min(DISPLAY_WIDTH) { + self.pixels[py * DISPLAY_WIDTH + px] = color; + } + } + } + + /// Draw a horizontal line. + pub fn draw_hline(&mut self, x: usize, y: usize, w: usize, color: u16) { + if y >= DISPLAY_HEIGHT { + return; + } + let start = y * DISPLAY_WIDTH + x; + let end = start + w.min(DISPLAY_WIDTH - x); + for i in start..end { + self.pixels[i] = color; + } + } + + /// Draw a vertical line. + pub fn draw_vline(&mut self, x: usize, y: usize, h: usize, color: u16) { + if x >= DISPLAY_WIDTH { + return; + } + for py in y..(y + h).min(DISPLAY_HEIGHT) { + self.pixels[py * DISPLAY_WIDTH + x] = color; + } + } + + /// Draw a single character using a simple 5x7 font. + /// Returns the width of the character drawn. + pub fn draw_char(&mut self, x: usize, y: usize, c: char, color: u16, scale: usize) -> usize { + let bitmap = get_char_bitmap(c); + let char_width = 5 * scale; + let char_height = 7 * scale; + + for (row, &bits) in bitmap.iter().enumerate() { + for col in 0..5 { + if (bits >> (4 - col)) & 1 == 1 { + // Draw scaled pixel + for sy in 0..scale { + for sx in 0..scale { + self.set_pixel(x + col * scale + sx, y + row * scale + sy, color); + } + } + } + } + } + + char_width + scale // Include spacing + } + + /// Draw a string. + pub fn draw_text(&mut self, x: usize, y: usize, text: &str, color: u16, scale: usize) { + let mut cursor_x = x; + for c in text.chars() { + cursor_x += self.draw_char(cursor_x, y, c, color, scale); + } + } + + /// Convert frame buffer to USB transfer format with XOR encoding. + /// + /// The Push 2 display requires each line to have a stride of 2048 bytes + /// (1024 pixels), even though only 960 pixels are visible. The extra + /// 128 bytes per line must be filled with XOR-encoded zeros. + pub fn to_usb_frame(&self) -> Vec { + // Total size: 160 lines * 2048 bytes per line + let total_size = DISPLAY_HEIGHT * LINE_STRIDE; + let mut data = Vec::with_capacity(total_size); + + // Process each line + for y in 0..DISPLAY_HEIGHT { + let line_start = y * DISPLAY_WIDTH; + + // Convert visible pixels (960 pixels = 1920 bytes) + for x in 0..DISPLAY_WIDTH { + let pixel = self.pixels[line_start + x]; + let bytes = pixel.to_le_bytes(); + let byte_offset = (x * 2) % 4; + data.push(bytes[0] ^ XOR_MASK[byte_offset]); + data.push(bytes[1] ^ XOR_MASK[(byte_offset + 1) % 4]); + } + + // Add padding bytes (128 bytes = 64 filler pixels) + // These are XOR-encoded zeros + let visible_bytes = DISPLAY_WIDTH * 2; // 1920 bytes + let padding_bytes = LINE_STRIDE - visible_bytes; // 128 bytes + for i in 0..padding_bytes { + let byte_offset = (visible_bytes + i) % 4; + data.push(0x00 ^ XOR_MASK[byte_offset]); + } + } + + data + } +} + +impl Default for FrameBuffer { + fn default() -> Self { + Self::new() + } +} + +/// Get bitmap for a character (5x7 font). +fn get_char_bitmap(c: char) -> [u8; 7] { + // Simple 5x7 bitmap font for ASCII printable characters + match c { + '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], + '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], + '2' => [0x0E, 0x11, 0x01, 0x06, 0x08, 0x10, 0x1F], + '3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E], + '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], + '5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], + '6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E], + '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], + '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C], + 'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], + 'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], + 'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E], + 'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], + 'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], + 'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F], + 'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + 'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + 'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C], + 'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], + 'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F], + 'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11], + 'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], + 'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10], + 'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D], + 'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11], + 'S' => [0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E], + 'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + 'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + 'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04], + 'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11], + 'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11], + 'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04], + 'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F], + 'a' => [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F], + 'b' => [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E], + 'c' => [0x00, 0x00, 0x0E, 0x11, 0x10, 0x11, 0x0E], + 'd' => [0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F], + 'e' => [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E], + 'f' => [0x06, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x08], + 'g' => [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E], + 'h' => [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11], + 'i' => [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E], + 'j' => [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C], + 'k' => [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12], + 'l' => [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + 'm' => [0x00, 0x00, 0x1A, 0x15, 0x15, 0x15, 0x15], + 'n' => [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11], + 'o' => [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E], + 'p' => [0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10], + 'q' => [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01], + 'r' => [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10], + 's' => [0x00, 0x00, 0x0E, 0x10, 0x0E, 0x01, 0x1E], + 't' => [0x08, 0x08, 0x1E, 0x08, 0x08, 0x09, 0x06], + 'u' => [0x00, 0x00, 0x11, 0x11, 0x11, 0x11, 0x0F], + 'v' => [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04], + 'w' => [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A], + 'x' => [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11], + 'y' => [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E], + 'z' => [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F], + ' ' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ':' => [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00], + '/' => [0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10], + '.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04], + '-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00], + _ => [0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F], // Box for unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rgb_to_bgr565() { + // White + assert_eq!(FrameBuffer::rgb_to_bgr565(255, 255, 255), 0xFFFF); + // Black + assert_eq!(FrameBuffer::rgb_to_bgr565(0, 0, 0), 0x0000); + // Red + assert_eq!(FrameBuffer::rgb_to_bgr565(255, 0, 0), 0xF800); + // Green + assert_eq!(FrameBuffer::rgb_to_bgr565(0, 255, 0), 0x07E0); + // Blue + assert_eq!(FrameBuffer::rgb_to_bgr565(0, 0, 255), 0x001F); + } + + #[test] + fn test_set_get_pixel() { + let mut fb = FrameBuffer::new(); + fb.set_pixel(100, 50, 0xF800); + assert_eq!(fb.get_pixel(100, 50), 0xF800); + } +} diff --git a/crates/push2/src/display/mod.rs b/crates/push2/src/display/mod.rs new file mode 100644 index 0000000..5bb9d6e --- /dev/null +++ b/crates/push2/src/display/mod.rs @@ -0,0 +1,13 @@ +//! Push 2 display subsystem. +//! +//! Handles USB communication with the Push 2 LCD display and rendering. + +mod driver; +mod frame_buffer; +mod renderer; +mod waveform; + +pub use driver::Push2Display; +pub use frame_buffer::FrameBuffer; +pub use renderer::DisplayRenderer; +pub use waveform::WaveformRenderer; diff --git a/crates/push2/src/display/renderer.rs b/crates/push2/src/display/renderer.rs new file mode 100644 index 0000000..d64e56d --- /dev/null +++ b/crates/push2/src/display/renderer.rs @@ -0,0 +1,279 @@ +//! Display renderer for Push 2. +//! +//! Renders DJ deck information and lighting status to the Push 2 display. + +use super::frame_buffer::{FrameBuffer, DISPLAY_HEIGHT, DISPLAY_WIDTH}; +use super::WaveformRenderer; +use crate::module::DeckDisplayState; + +/// Width of each deck section (half the display) +const DECK_WIDTH: usize = DISPLAY_WIDTH / 2; + +/// Colors (BGR565 format) +mod colors { + pub const BLACK: u16 = 0x0000; + pub const WHITE: u16 = 0xFFFF; + pub const RED: u16 = 0xF800; + pub const GREEN: u16 = 0x07E0; + pub const BLUE: u16 = 0x001F; + pub const ORANGE: u16 = 0xFD20; + pub const CYAN: u16 = 0x07FF; + pub const GRAY: u16 = 0x8410; + pub const DARK_GRAY: u16 = 0x4208; +} + +/// Display renderer for Push 2. +pub struct DisplayRenderer { + waveform_a: WaveformRenderer, + waveform_b: WaveformRenderer, +} + +impl DisplayRenderer { + /// Create a new display renderer. + pub fn new() -> Self { + Self { + waveform_a: WaveformRenderer::new(), + waveform_b: WaveformRenderer::new(), + } + } + + /// Render the full display with interpolated positions for smooth scrolling. + pub fn render_with_positions( + &mut self, + buffer: &mut FrameBuffer, + deck_a: &DeckDisplayState, + deck_b: &DeckDisplayState, + pos_a: f64, + pos_b: f64, + ) { + buffer.clear(); + + // Draw center divider + buffer.draw_vline(DECK_WIDTH - 1, 0, DISPLAY_HEIGHT, colors::DARK_GRAY); + buffer.draw_vline(DECK_WIDTH, 0, DISPLAY_HEIGHT, colors::DARK_GRAY); + + // Render each deck with interpolated position + self.render_deck(buffer, deck_a, 0, pos_a); + self.render_deck(buffer, deck_b, DECK_WIDTH + 2, pos_b); + } + + /// Render a single deck section. + fn render_deck( + &mut self, + buffer: &mut FrameBuffer, + deck: &DeckDisplayState, + x_offset: usize, + position: f64, + ) { + // Waveform area (top 60 pixels) + self.render_waveform(buffer, deck, x_offset, 0, DECK_WIDTH - 4, 60, position); + + // Track info (60-100) + self.render_track_info(buffer, deck, x_offset, 62); + + // Transport state (100-130) - pass interpolated position + self.render_transport(buffer, deck, x_offset, 102, position); + + // BPM (130-160) + self.render_bpm(buffer, deck, x_offset, 132); + } + + /// Render zoomed waveform centered on playhead (optimized). + fn render_waveform( + &self, + buffer: &mut FrameBuffer, + deck: &DeckDisplayState, + x: usize, + y: usize, + w: usize, + h: usize, + position: f64, + ) { + // Draw waveform background + buffer.draw_rect(x, y, w, h, colors::DARK_GRAY); + + // If no track loaded, show empty state + if deck.title.is_empty() { + buffer.draw_text(x + 10, y + h / 2 - 4, "No Track", colors::GRAY, 1); + return; + } + + let center_y = y + h / 2; + let half_height = (h / 2) as f32; + let waveform = &deck.waveform; + + // Check if we have waveform data + if waveform.amplitudes.is_empty() || deck.duration_seconds <= 0.0 { + buffer.draw_hline(x, center_y, w, colors::GRAY); + buffer.draw_vline(x + w / 2, y, h, colors::WHITE); + return; + } + + // Visible time window (~8 seconds centered on playhead) + let visible_seconds = 8.0; + let start_time = (position - visible_seconds / 2.0).max(0.0); + let end_time = (position + visible_seconds / 2.0).min(deck.duration_seconds); + + // Use pre-computed samples_per_second + let start_sample = (start_time * waveform.samples_per_second) as usize; + let end_sample = + ((end_time * waveform.samples_per_second) as usize).min(waveform.amplitudes.len()); + let samples_in_view = end_sample.saturating_sub(start_sample); + + if samples_in_view > 0 { + // Pre-calculate step for sample selection + let step = samples_in_view as f32 / w as f32; + + for px in 0..w { + let sample_idx = start_sample + (px as f32 * step) as usize; + if sample_idx >= waveform.amplitudes.len() { + continue; + } + + let amplitude = waveform.amplitudes[sample_idx]; + let bar_height = (amplitude * half_height * 0.9) as usize; + + if bar_height == 0 { + continue; + } + + // Use pre-computed color + let color = waveform.colors[sample_idx]; + + // Draw mirrored waveform (optimized: draw line instead of pixel-by-pixel) + let top_y = center_y.saturating_sub(bar_height); + let bot_y = (center_y + bar_height).min(y + h - 1); + buffer.draw_vline(x + px, top_y, bot_y - top_y, color); + } + } + + // Draw center line + buffer.draw_hline(x, center_y, w, colors::GRAY); + + // Calculate playhead position using interpolated position + let playhead_x = if position < visible_seconds / 2.0 { + x + ((w / 2) as f64 * (position / (visible_seconds / 2.0))) as usize + } else if position > deck.duration_seconds - visible_seconds / 2.0 { + let time_from_end = deck.duration_seconds - position; + x + w - ((w / 2) as f64 * (time_from_end / (visible_seconds / 2.0))) as usize + } else { + x + w / 2 + }; + + // Draw playhead + buffer.draw_vline(playhead_x, y, h, colors::WHITE); + + // Draw cue point if visible + if let Some(cue) = deck.cue_point { + if cue >= start_time && cue <= end_time { + let cue_x = + x + (((cue - start_time) / (end_time - start_time)) * w as f64) as usize; + buffer.draw_vline(cue_x, y, h, colors::ORANGE); + } + } + + // Draw hot cues if visible + for (i, hot_cue) in deck.hot_cues.iter().enumerate() { + if let Some(pos) = hot_cue { + if *pos >= start_time && *pos <= end_time { + let hc_x = + x + (((*pos - start_time) / (end_time - start_time)) * w as f64) as usize; + let color = match i { + 0 => colors::RED, + 1 => colors::GREEN, + 2 => colors::BLUE, + _ => colors::CYAN, + }; + buffer.draw_vline(hc_x, y, 8, color); + } + } + } + } + + /// Render track title and artist. + fn render_track_info( + &self, + buffer: &mut FrameBuffer, + deck: &DeckDisplayState, + x: usize, + y: usize, + ) { + if deck.title.is_empty() { + return; + } + + // Truncate title if too long + let max_chars = (DECK_WIDTH - 10) / 6; // 6 pixels per char at scale 1 + let title = if deck.title.len() > max_chars { + format!("{}...", &deck.title[..max_chars - 3]) + } else { + deck.title.clone() + }; + + buffer.draw_text(x + 4, y, &title, colors::WHITE, 2); + + // Artist (smaller, below title) + if !deck.artist.is_empty() { + let artist = if deck.artist.len() > max_chars { + format!("{}...", &deck.artist[..max_chars - 3]) + } else { + deck.artist.clone() + }; + buffer.draw_text(x + 4, y + 18, &artist, colors::GRAY, 1); + } + } + + /// Render transport state (play/pause, sync, master). + fn render_transport( + &self, + buffer: &mut FrameBuffer, + deck: &DeckDisplayState, + x: usize, + y: usize, + position: f64, + ) { + // Time display using interpolated position + let pos_min = (position / 60.0) as u32; + let pos_sec = (position % 60.0) as u32; + let dur_min = (deck.duration_seconds / 60.0) as u32; + let dur_sec = (deck.duration_seconds % 60.0) as u32; + + let time_str = format!( + "{:02}:{:02} / {:02}:{:02}", + pos_min, pos_sec, dur_min, dur_sec + ); + buffer.draw_text(x + 4, y, &time_str, colors::WHITE, 1); + + // Play/Pause indicator + let transport_y = y + 12; + if deck.is_playing { + buffer.draw_text(x + 4, transport_y, "PLAY", colors::GREEN, 1); + } else { + buffer.draw_text(x + 4, transport_y, "PAUSE", colors::ORANGE, 1); + } + + // Sync indicator + if deck.sync_enabled { + buffer.draw_text(x + 60, transport_y, "SYNC", colors::CYAN, 1); + } + + // Master indicator + if deck.is_master { + buffer.draw_text(x + 110, transport_y, "MASTER", colors::ORANGE, 1); + } + } + + /// Render BPM display. + fn render_bpm(&self, buffer: &mut FrameBuffer, deck: &DeckDisplayState, x: usize, y: usize) { + if deck.bpm > 0.0 { + let bpm_str = format!("{:.2} BPM", deck.bpm); + buffer.draw_text(x + 4, y, &bpm_str, colors::WHITE, 2); + } + } +} + +impl Default for DisplayRenderer { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/push2/src/display/waveform.rs b/crates/push2/src/display/waveform.rs new file mode 100644 index 0000000..3642002 --- /dev/null +++ b/crates/push2/src/display/waveform.rs @@ -0,0 +1,56 @@ +//! Waveform visualization for Push 2 display. + +/// Waveform renderer for DJ decks. +/// +/// Displays an overview waveform with position indicator, +/// cue points, and hot cue markers. +pub struct WaveformRenderer { + /// Cached waveform data (amplitude samples) + waveform_data: Vec, + + /// Track duration in seconds + duration_seconds: f64, +} + +impl WaveformRenderer { + /// Create a new waveform renderer. + pub fn new() -> Self { + Self { + waveform_data: Vec::new(), + duration_seconds: 0.0, + } + } + + /// Set waveform data for a track. + pub fn set_waveform(&mut self, data: Vec, duration: f64) { + self.waveform_data = data; + self.duration_seconds = duration; + } + + /// Clear waveform data. + pub fn clear(&mut self) { + self.waveform_data.clear(); + self.duration_seconds = 0.0; + } + + /// Check if waveform data is loaded. + pub fn has_data(&self) -> bool { + !self.waveform_data.is_empty() + } + + /// Get amplitude at a given position (0.0-1.0 of track duration). + pub fn amplitude_at(&self, position: f64) -> f32 { + if self.waveform_data.is_empty() || position < 0.0 || position > 1.0 { + return 0.0; + } + + let index = (position * (self.waveform_data.len() - 1) as f64) as usize; + self.waveform_data.get(index).copied().unwrap_or(0.0) + } +} + +impl Default for WaveformRenderer { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/push2/src/lib.rs b/crates/push2/src/lib.rs new file mode 100644 index 0000000..bfc9168 --- /dev/null +++ b/crates/push2/src/lib.rs @@ -0,0 +1,24 @@ +//! Ableton Push 2 integration for Halo lighting console. +//! +//! This crate provides full Push 2 support including: +//! - USB display driver for the 960x160 LCD +//! - MIDI control for pads, encoders, and buttons +//! - LED feedback reflecting console state +//! +//! # Architecture +//! +//! The Push 2 is controlled via two interfaces: +//! - **USB**: For the LCD display (vendor ID 0x2982, product ID 0x1967) +//! - **MIDI**: For pads, encoders, buttons, and LED feedback +//! +//! # Pad Layout +//! +//! The 8x8 pad grid is split between DJ and lighting: +//! - Top 4 rows (notes 68-99): DJ controls (hot cues, transport, sync) +//! - Bottom 4 rows (notes 36-67): Lighting controls (cue triggers, fixtures) + +pub mod display; +pub mod midi; +pub mod module; + +pub use module::Push2Module; diff --git a/crates/push2/src/midi/led_feedback.rs b/crates/push2/src/midi/led_feedback.rs new file mode 100644 index 0000000..a610119 --- /dev/null +++ b/crates/push2/src/midi/led_feedback.rs @@ -0,0 +1,317 @@ +//! LED feedback for Push 2 pads. +//! +//! Manages the color state of Push 2 pads and generates MIDI messages +//! to update the LEDs. + +use halo_dj::deck::DeckId; + +/// Push 2 pad color palette indices. +/// +/// The Push 2 uses a velocity-based color palette. +/// These are common colors from the palette. +pub mod colors { + pub const OFF: u8 = 0; + pub const WHITE: u8 = 3; + pub const RED: u8 = 5; + pub const RED_DIM: u8 = 6; + pub const ORANGE: u8 = 9; + pub const ORANGE_DIM: u8 = 10; + pub const YELLOW: u8 = 13; + pub const YELLOW_DIM: u8 = 14; + pub const GREEN: u8 = 21; + pub const GREEN_DIM: u8 = 22; + pub const CYAN: u8 = 33; + pub const CYAN_DIM: u8 = 34; + pub const BLUE: u8 = 45; + pub const BLUE_DIM: u8 = 46; + pub const PURPLE: u8 = 49; + pub const PURPLE_DIM: u8 = 50; + pub const PINK: u8 = 57; + pub const PINK_DIM: u8 = 58; +} + +/// Color for a single pad. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PadColor { + /// Color palette index (velocity) + pub color: u8, + /// Animation mode (0=static, 1=blink, 2=pulse) + pub animation: u8, +} + +impl PadColor { + pub const fn new(color: u8) -> Self { + Self { + color, + animation: 0, + } + } + + pub const fn off() -> Self { + Self::new(colors::OFF) + } + + pub const fn with_animation(color: u8, animation: u8) -> Self { + Self { color, animation } + } +} + +impl Default for PadColor { + fn default() -> Self { + Self::off() + } +} + +/// LED state for all Push 2 pads. +pub struct LedState { + /// 8x8 grid of pad colors (64 pads, notes 36-99) + pads: [[PadColor; 8]; 8], + + /// Dirty flags for each pad (needs update) + dirty: [[bool; 8]; 8], +} + +impl LedState { + /// Create a new LED state with all pads off. + pub fn new() -> Self { + let mut state = Self { + pads: [[PadColor::off(); 8]; 8], + dirty: [[true; 8]; 8], // Mark all as dirty initially + }; + + // Set default colors for DJ row labels + state.set_default_colors(); + + state + } + + /// Set default pad colors for the layout. + fn set_default_colors(&mut self) { + // Row 8 (Hot cues) - different colors per slot + for i in 0..4 { + // Deck A hot cues + self.set_pad_color(7, i, PadColor::new(colors::RED_DIM)); + // Deck B hot cues + self.set_pad_color(7, 4 + i, PadColor::new(colors::BLUE_DIM)); + } + + // Row 7 (Transport) - dim until active + for i in 0..8 { + self.set_pad_color(6, i, PadColor::new(colors::WHITE)); + } + + // Row 4-3 (Cue triggers) - blue dim + for row in 4..6 { + for col in 0..8 { + self.set_pad_color(row, col, PadColor::new(colors::BLUE_DIM)); + } + } + + // Row 2 (Fixtures) - cyan dim + for col in 0..8 { + self.set_pad_color(1, col, PadColor::new(colors::CYAN_DIM)); + } + + // Row 1 (Effects/Transport) + self.set_pad_color(0, 4, PadColor::new(colors::GREEN)); // GO + self.set_pad_color(0, 5, PadColor::new(colors::RED)); // STOP + self.set_pad_color(0, 6, PadColor::new(colors::ORANGE)); // PREV + self.set_pad_color(0, 7, PadColor::new(colors::ORANGE)); // NEXT + } + + /// Set the color of a pad by row and column. + pub fn set_pad_color(&mut self, row: usize, col: usize, color: PadColor) { + if row < 8 && col < 8 { + if self.pads[row][col] != color { + self.pads[row][col] = color; + self.dirty[row][col] = true; + } + } + } + + /// Set pad color by MIDI note number. + pub fn set_pad_color_by_note(&mut self, note: u8, color: PadColor) { + if (36..=99).contains(¬e) { + let index = (note - 36) as usize; + let row = index / 8; + let col = index % 8; + self.set_pad_color(row, col, color); + } + } + + /// Update hot cue LED based on state. + pub fn update_hot_cue(&mut self, deck: DeckId, slot: u8, is_set: bool) { + let col = match deck { + DeckId::A => slot as usize, + DeckId::B => 4 + slot as usize, + }; + + let color = if is_set { + match slot { + 0 => PadColor::new(colors::RED), + 1 => PadColor::new(colors::GREEN), + 2 => PadColor::new(colors::BLUE), + 3 => PadColor::new(colors::YELLOW), + _ => PadColor::new(colors::WHITE), + } + } else { + match deck { + DeckId::A => PadColor::new(colors::RED_DIM), + DeckId::B => PadColor::new(colors::BLUE_DIM), + } + }; + + self.set_pad_color(7, col, color); + } + + /// Update transport LED based on playing state. + pub fn update_transport(&mut self, deck: DeckId, is_playing: bool) { + let col = match deck { + DeckId::A => 1, // PLAY_A + DeckId::B => 5, // PLAY_B + }; + + let color = if is_playing { + PadColor::new(colors::GREEN) + } else { + PadColor::new(colors::GREEN_DIM) + }; + + self.set_pad_color(6, col, color); + } + + /// Update sync LED. + pub fn update_sync(&mut self, deck: DeckId, sync_enabled: bool) { + let col = match deck { + DeckId::A => 2, // SYNC_A + DeckId::B => 6, // SYNC_B + }; + + let color = if sync_enabled { + PadColor::new(colors::CYAN) + } else { + PadColor::new(colors::CYAN_DIM) + }; + + self.set_pad_color(6, col, color); + } + + /// Update master LED. + pub fn update_master(&mut self, deck: DeckId, is_master: bool) { + let col = match deck { + DeckId::A => 3, // MASTER_A + DeckId::B => 7, // MASTER_B + }; + + let color = if is_master { + PadColor::new(colors::ORANGE) + } else { + PadColor::new(colors::ORANGE_DIM) + }; + + self.set_pad_color(6, col, color); + } + + /// Update cue trigger LED. + pub fn update_cue_trigger(&mut self, cue_index: usize, is_active: bool) { + if cue_index < 16 { + let row = if cue_index < 8 { 5 } else { 4 }; + let col = cue_index % 8; + + let color = if is_active { + PadColor::with_animation(colors::BLUE, 1) // Blink when active + } else { + PadColor::new(colors::BLUE_DIM) + }; + + self.set_pad_color(row, col, color); + } + } + + /// Update fixture selection LED. + pub fn update_fixture_selection(&mut self, fixture_index: usize, is_selected: bool) { + if fixture_index < 8 { + let color = if is_selected { + PadColor::new(colors::CYAN) + } else { + PadColor::new(colors::CYAN_DIM) + }; + self.set_pad_color(1, fixture_index, color); + } + } + + /// Clear all LEDs. + pub fn clear(&mut self) { + for row in 0..8 { + for col in 0..8 { + self.set_pad_color(row, col, PadColor::off()); + } + } + } + + /// Generate MIDI messages for all dirty pads. + pub fn to_midi_messages(&mut self) -> Vec<[u8; 3]> { + let mut messages = Vec::new(); + + for row in 0..8 { + for col in 0..8 { + if self.dirty[row][col] { + let note = 36 + (row * 8 + col) as u8; + let color = &self.pads[row][col]; + + // Note On message with velocity = color + messages.push([0x90, note, color.color]); + + self.dirty[row][col] = false; + } + } + } + + messages + } + + /// Generate MIDI messages for all pads (full refresh). + pub fn to_midi_messages_full(&self) -> Vec<[u8; 3]> { + let mut messages = Vec::new(); + + for row in 0..8 { + for col in 0..8 { + let note = 36 + (row * 8 + col) as u8; + let color = &self.pads[row][col]; + messages.push([0x90, note, color.color]); + } + } + + messages + } +} + +impl Default for LedState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pad_note_calculation() { + // Row 0, Col 0 = note 36 + // Row 7, Col 7 = note 99 + assert_eq!(36 + (0 * 8 + 0), 36); + assert_eq!(36 + (7 * 8 + 7), 99); + } + + #[test] + fn test_led_state_update() { + let mut state = LedState::new(); + + state.update_transport(DeckId::A, true); + let messages = state.to_midi_messages(); + + // Should have at least one message for the play button + assert!(!messages.is_empty()); + } +} diff --git a/crates/push2/src/midi/mapping.rs b/crates/push2/src/midi/mapping.rs new file mode 100644 index 0000000..4c8a9be --- /dev/null +++ b/crates/push2/src/midi/mapping.rs @@ -0,0 +1,290 @@ +//! Push 2 MIDI mapping. +//! +//! Maps Push 2 pads, encoders, and buttons to DJ and lighting commands. +//! +//! # Pad Layout (8x8 grid, notes 36-99) +//! +//! ```text +//! DJ (Top Half - notes 68-99): +//! Row 8 (92-99): Hot Cues A1-4, B1-4 +//! Row 7 (84-91): CUE, PLAY, SYNC, MASTER for each deck +//! Row 6 (76-83): Seek, Loop, Load for each deck +//! Row 5 (68-75): Global DJ controls +//! +//! Lighting (Bottom Half - notes 36-67): +//! Row 4 (60-67): Cue triggers 1-8 +//! Row 3 (52-59): Cue triggers 9-16 +//! Row 2 (44-51): Fixture selection +//! Row 1 (36-43): Effects, GO, STOP, PREV, NEXT +//! ``` + +use halo_core::ConsoleCommand; + +/// Push 2 MIDI mapping constants and translation. +pub struct Push2Mapping; + +impl Push2Mapping { + // === Pad Notes (8x8 grid) === + + // Row 8: Hot Cues (notes 92-99) + pub const HOT_CUE_A_1: u8 = 92; + pub const HOT_CUE_A_2: u8 = 93; + pub const HOT_CUE_A_3: u8 = 94; + pub const HOT_CUE_A_4: u8 = 95; + pub const HOT_CUE_B_1: u8 = 96; + pub const HOT_CUE_B_2: u8 = 97; + pub const HOT_CUE_B_3: u8 = 98; + pub const HOT_CUE_B_4: u8 = 99; + + // Row 7: Transport (notes 84-91) + pub const CUE_A: u8 = 84; + pub const PLAY_A: u8 = 85; + pub const SYNC_A: u8 = 86; + pub const MASTER_A: u8 = 87; + pub const CUE_B: u8 = 88; + pub const PLAY_B: u8 = 89; + pub const SYNC_B: u8 = 90; + pub const MASTER_B: u8 = 91; + + // Row 6: Navigation (notes 76-83) + pub const SEEK_BACK_A: u8 = 76; + pub const SEEK_FWD_A: u8 = 77; + pub const LOOP_A: u8 = 78; + pub const LOAD_A: u8 = 79; + pub const SEEK_BACK_B: u8 = 80; + pub const SEEK_FWD_B: u8 = 81; + pub const LOOP_B: u8 = 82; + pub const LOAD_B: u8 = 83; + + // Row 5: Global DJ (notes 68-75) + pub const TEMPO_RANGE: u8 = 68; + pub const ABLETON_LINK: u8 = 69; + pub const TAP_TEMPO: u8 = 70; + pub const _DJ_MODE: u8 = 71; + pub const _BPM_MINUS: u8 = 72; + pub const _BPM_PLUS: u8 = 73; + pub const _RESERVED_1: u8 = 74; + pub const BUTTON_SHIFT: u8 = 75; + + // Row 4: Cue triggers 1-8 (notes 60-67) + // Row 3: Cue triggers 9-16 (notes 52-59) + // Row 2: Fixture selection (notes 44-51) + // Row 1: Effects/transport (notes 36-43) + pub const GO: u8 = 40; + pub const STOP: u8 = 41; + pub const PREV_CUE: u8 = 42; + pub const NEXT_CUE_LIST: u8 = 43; + + // === Encoders (CC 71-78) === + pub const ENCODER_1: u8 = 71; + pub const ENCODER_2: u8 = 72; + pub const ENCODER_3: u8 = 73; + pub const ENCODER_4: u8 = 74; + pub const ENCODER_5: u8 = 75; + pub const ENCODER_6: u8 = 76; + pub const ENCODER_7: u8 = 77; + pub const ENCODER_8: u8 = 78; + + // Touch strip + pub const TOUCH_STRIP: u8 = 12; + + /// Translate a MIDI note on message to a console command. + pub fn translate_note_on(note: u8, velocity: u8, shift_held: bool) -> Option { + // Hot Cues (Row 8) + match note { + Self::HOT_CUE_A_1..=Self::HOT_CUE_A_4 => { + let slot = note - Self::HOT_CUE_A_1; + if shift_held { + return Some(ConsoleCommand::DjSetHotCue { deck: 0, slot }); + } else { + return Some(ConsoleCommand::DjJumpToHotCue { deck: 0, slot }); + } + } + Self::HOT_CUE_B_1..=Self::HOT_CUE_B_4 => { + let slot = note - Self::HOT_CUE_B_1; + if shift_held { + return Some(ConsoleCommand::DjSetHotCue { deck: 1, slot }); + } else { + return Some(ConsoleCommand::DjJumpToHotCue { deck: 1, slot }); + } + } + _ => {} + } + + // Transport (Row 7) + match note { + Self::CUE_A => { + return Some(ConsoleCommand::DjCuePreview { + deck: 0, + pressed: true, + }); + } + Self::PLAY_A => return Some(ConsoleCommand::DjPlay { deck: 0 }), + Self::SYNC_A => return Some(ConsoleCommand::DjToggleSync { deck: 0 }), + Self::MASTER_A => return Some(ConsoleCommand::DjSetMaster { deck: 0 }), + + Self::CUE_B => { + return Some(ConsoleCommand::DjCuePreview { + deck: 1, + pressed: true, + }); + } + Self::PLAY_B => return Some(ConsoleCommand::DjPlay { deck: 1 }), + Self::SYNC_B => return Some(ConsoleCommand::DjToggleSync { deck: 1 }), + Self::MASTER_B => return Some(ConsoleCommand::DjSetMaster { deck: 1 }), + _ => {} + } + + // Navigation (Row 6) + match note { + Self::SEEK_BACK_A => { + return Some(ConsoleCommand::DjSeekBeats { deck: 0, beats: -4 }); + } + Self::SEEK_FWD_A => { + return Some(ConsoleCommand::DjSeekBeats { deck: 0, beats: 4 }); + } + Self::SEEK_BACK_B => { + return Some(ConsoleCommand::DjSeekBeats { deck: 1, beats: -4 }); + } + Self::SEEK_FWD_B => { + return Some(ConsoleCommand::DjSeekBeats { deck: 1, beats: 4 }); + } + _ => {} + } + + // Global DJ (Row 5) + match note { + Self::TAP_TEMPO => return Some(ConsoleCommand::TapTempo), + Self::ABLETON_LINK => return Some(ConsoleCommand::ToggleAbletonLink), + _ => {} + } + + None + } + + /// Translate a MIDI note off message to a console command. + pub fn translate_note_off(note: u8) -> Option { + // Release cue preview + match note { + Self::CUE_A => { + return Some(ConsoleCommand::DjCuePreview { + deck: 0, + pressed: false, + }); + } + Self::CUE_B => { + return Some(ConsoleCommand::DjCuePreview { + deck: 1, + pressed: false, + }); + } + _ => {} + } + + None + } + + /// Translate a MIDI control change message to a console command. + /// + /// Encoders send relative values: 64 = no change, <64 = decrease, >64 = increase. + pub fn translate_cc(cc: u8, value: u8) -> Option { + // Calculate relative delta (-63 to +63) + let delta = value as i8 - 64; + if delta == 0 { + return None; + } + + match cc { + // Encoder 1: Deck A pitch + Self::ENCODER_1 => { + // Each tick = 0.5% pitch change + let pitch_delta = delta as f64 * 0.005; + Some(ConsoleCommand::DjNudgePitch { + deck: 0, + delta: pitch_delta, + }) + } + + // Encoder 5: Deck B pitch + Self::ENCODER_5 => { + let pitch_delta = delta as f64 * 0.005; + Some(ConsoleCommand::DjNudgePitch { + deck: 1, + delta: pitch_delta, + }) + } + + // Touch strip: scrub/seek + Self::TOUCH_STRIP => { + // Absolute position 0-127 maps to track position + let position = value as f64 / 127.0; + // This would need the current track duration to calculate seconds + // For now, we'll send a normalized position + None // TODO: Implement scrub with track duration context + } + + _ => None, + } + } + + /// Get the Push 2 device name for MIDI port matching. + pub fn device_name() -> &'static str { + "Ableton Push 2" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hot_cue_mapping() { + let cmd = Push2Mapping::translate_note_on(Push2Mapping::HOT_CUE_A_1, 127, false); + assert!(matches!( + cmd, + Some(ConsoleCommand::DjJumpToHotCue { deck: 0, slot: 0 }) + )); + + // With shift = set hot cue + let cmd = Push2Mapping::translate_note_on(Push2Mapping::HOT_CUE_A_1, 127, true); + assert!(matches!( + cmd, + Some(ConsoleCommand::DjSetHotCue { deck: 0, slot: 0 }) + )); + } + + #[test] + fn test_transport_mapping() { + let cmd = Push2Mapping::translate_note_on(Push2Mapping::PLAY_A, 127, false); + assert!(matches!(cmd, Some(ConsoleCommand::DjPlay { deck: 0 }))); + + let cmd = Push2Mapping::translate_note_on(Push2Mapping::SYNC_B, 127, false); + assert!(matches!( + cmd, + Some(ConsoleCommand::DjToggleSync { deck: 1 }) + )); + } + + #[test] + fn test_cue_preview() { + // Note on = press + let cmd = Push2Mapping::translate_note_on(Push2Mapping::CUE_A, 127, false); + assert!(matches!( + cmd, + Some(ConsoleCommand::DjCuePreview { + deck: 0, + pressed: true + }) + )); + + // Note off = release + let cmd = Push2Mapping::translate_note_off(Push2Mapping::CUE_A); + assert!(matches!( + cmd, + Some(ConsoleCommand::DjCuePreview { + deck: 0, + pressed: false + }) + )); + } +} diff --git a/crates/push2/src/midi/mod.rs b/crates/push2/src/midi/mod.rs new file mode 100644 index 0000000..842d47a --- /dev/null +++ b/crates/push2/src/midi/mod.rs @@ -0,0 +1,9 @@ +//! Push 2 MIDI handling. +//! +//! Handles pad/encoder input and LED feedback. + +mod led_feedback; +mod mapping; + +pub use led_feedback::LedState; +pub use mapping::Push2Mapping; diff --git a/crates/push2/src/module.rs b/crates/push2/src/module.rs new file mode 100644 index 0000000..adf9ad9 --- /dev/null +++ b/crates/push2/src/module.rs @@ -0,0 +1,723 @@ +//! Push2Module - Async module for Ableton Push 2 integration. + +use std::collections::HashMap; +use std::time::Duration; + +use async_trait::async_trait; +use halo_core::{AsyncModule, ConsoleCommand, ModuleEvent, ModuleId, ModuleMessage}; +use halo_dj::deck::DeckId; +use midir::{MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection}; +use tokio::sync::mpsc; + +use crate::display::{DisplayRenderer, FrameBuffer, Push2Display}; +use crate::midi::{LedState, Push2Mapping}; + +/// Push 2 operating mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Push2Mode { + /// Normal operation + Normal, + /// Shift button held - alternate functions + Shift, + /// Settings/configuration mode + Settings, +} + +/// Pre-computed waveform data optimized for display +#[derive(Debug, Clone, Default)] +pub struct DisplayWaveform { + /// Downsampled amplitude values (one per display pixel at max zoom) + pub amplitudes: Vec, + /// Pre-computed BGR565 colors for each sample + pub colors: Vec, + /// Samples per second (for time-to-index conversion) + pub samples_per_second: f64, +} + +/// State for DJ deck display +#[derive(Debug, Clone)] +pub struct DeckDisplayState { + pub title: String, + pub artist: String, + pub duration_seconds: f64, + pub position_seconds: f64, + pub bpm: f64, + pub is_playing: bool, + pub is_master: bool, + pub sync_enabled: bool, + pub cue_point: Option, + pub hot_cues: [Option; 4], + /// Pre-computed waveform for fast rendering + pub waveform: DisplayWaveform, + /// Last time we received a position update (for interpolation) + pub last_update: std::time::Instant, +} + +impl Default for DeckDisplayState { + fn default() -> Self { + Self { + title: String::new(), + artist: String::new(), + duration_seconds: 0.0, + position_seconds: 0.0, + bpm: 0.0, + is_playing: false, + is_master: false, + sync_enabled: false, + cue_point: None, + hot_cues: [None; 4], + waveform: DisplayWaveform::default(), + last_update: std::time::Instant::now(), + } + } +} + +/// State for lighting display +#[derive(Debug, Clone, Default)] +pub struct LightingDisplayState { + pub current_cue_list: String, + pub current_cue_index: usize, + pub total_cues: usize, + pub selected_fixtures: Vec, +} + +/// Ableton Push 2 controller module. +/// +/// Provides integration with the Push 2 hardware including: +/// - USB display for waveforms and track info +/// - MIDI pads for DJ and lighting control +/// - LED feedback for visual state indication +pub struct Push2Module { + /// USB display connection (None if not connected) + display: Option, + + /// Frame buffer for display rendering + frame_buffer: FrameBuffer, + + /// Display renderer + renderer: DisplayRenderer, + + /// MIDI input connection + midi_input: Option>>>, + + /// MIDI output connection for LED feedback + midi_output: Option, + + /// LED state + led_state: LedState, + + /// DJ deck A state + deck_a: DeckDisplayState, + + /// DJ deck B state + deck_b: DeckDisplayState, + + /// Lighting state + lighting_state: LightingDisplayState, + + /// Current operating mode + mode: Push2Mode, + + /// Shift button held + shift_held: bool, + + /// Module status + status: HashMap, + + /// MIDI message receiver (from callback) + midi_rx: Option>>, +} + +impl Push2Module { + /// Create a new Push2Module. + pub fn new() -> Self { + Self { + display: None, + frame_buffer: FrameBuffer::new(), + renderer: DisplayRenderer::new(), + midi_input: None, + midi_output: None, + led_state: LedState::new(), + deck_a: DeckDisplayState::default(), + deck_b: DeckDisplayState::default(), + lighting_state: LightingDisplayState::default(), + mode: Push2Mode::Normal, + shift_held: false, + status: HashMap::new(), + midi_rx: None, + } + } + + /// Try to connect to the Push 2 display via USB. + fn connect_display(&mut self) -> Result<(), Box> { + // List USB devices for debugging + let usb_devices = Push2Display::list_usb_devices(); + let push2_found = usb_devices.iter().any(|d| d.contains("Push 2")); + + tracing::debug!("USB devices found: {:?}", usb_devices); + + match Push2Display::new() { + Ok(display) => { + self.display = Some(display); + self.status + .insert("display".to_string(), "connected".to_string()); + eprintln!("Push 2 display: connected via USB"); + tracing::info!("Push 2 display connected"); + Ok(()) + } + Err(e) => { + self.display = None; + self.status + .insert("display".to_string(), "not_connected".to_string()); + + // Print detailed diagnostic info to stderr for visibility + eprintln!("WARNING: Push 2 display not available: {e}"); + eprintln!(" USB devices found ({} total):", usb_devices.len()); + for device in &usb_devices { + eprintln!(" - {device}"); + } + if !push2_found { + eprintln!(" Push 2 NOT detected in USB device list!"); + eprintln!(" Check: Is Push 2 connected via USB? (Not just MIDI)"); + } else { + eprintln!(" Push 2 detected but display connection failed."); + eprintln!( + " On macOS: Check System Preferences > Privacy & Security > USB access" + ); + } + eprintln!(" Continuing in MIDI-only mode (LEDs work, display blank)."); + + tracing::warn!("Push 2 display not available: {e}. MIDI-only mode."); + // Don't fail - continue with MIDI only + Ok(()) + } + } + } + + /// Try to connect to the Push 2 MIDI ports. + fn connect_midi(&mut self) -> Result<(), Box> { + // Create MIDI input + let midi_in = MidiInput::new("halo_push2_in")?; + + // Find Push 2 input port + let in_ports = midi_in.ports(); + let in_port = in_ports.iter().find(|p| { + midi_in + .port_name(p) + .map(|n| n.contains("Ableton Push 2") || n.contains("Push 2")) + .unwrap_or(false) + }); + + let in_port = match in_port { + Some(p) => p.clone(), + None => { + self.status + .insert("midi_input".to_string(), "not_found".to_string()); + return Err("Push 2 MIDI input not found".into()); + } + }; + + // Create channel for MIDI messages + let (tx, rx) = mpsc::unbounded_channel(); + self.midi_rx = Some(rx); + + // Connect MIDI input + let connection = midi_in.connect( + &in_port, + "push2-input", + move |_timestamp, message, tx| { + // Send raw MIDI bytes to async handler + let _ = tx.send(message.to_vec()); + }, + tx, + )?; + + self.midi_input = Some(connection); + self.status + .insert("midi_input".to_string(), "connected".to_string()); + + // Create MIDI output + let midi_out = MidiOutput::new("halo_push2_out")?; + let out_ports = midi_out.ports(); + let out_port = out_ports.iter().find(|p| { + midi_out + .port_name(p) + .map(|n| n.contains("Ableton Push 2") || n.contains("Push 2")) + .unwrap_or(false) + }); + + if let Some(port) = out_port { + let connection = midi_out.connect(port, "push2-output")?; + self.midi_output = Some(connection); + self.status + .insert("midi_output".to_string(), "connected".to_string()); + } else { + self.status + .insert("midi_output".to_string(), "not_found".to_string()); + tracing::warn!("Push 2 MIDI output not found - LED feedback disabled"); + } + + tracing::info!("Push 2 MIDI connected"); + Ok(()) + } + + /// Handle incoming MIDI message. + fn handle_midi_message(&mut self, message: &[u8]) -> Option { + if message.is_empty() { + return None; + } + + let status = message[0] & 0xF0; + match status { + 0x90 => { + // Note On + if message.len() >= 3 { + let note = message[1]; + let velocity = message[2]; + if velocity > 0 { + self.handle_pad_press(note, velocity) + } else { + self.handle_pad_release(note) + } + } else { + None + } + } + 0x80 => { + // Note Off + if message.len() >= 2 { + let note = message[1]; + self.handle_pad_release(note) + } else { + None + } + } + 0xB0 => { + // Control Change + if message.len() >= 3 { + let cc = message[1]; + let value = message[2]; + self.handle_cc(cc, value) + } else { + None + } + } + _ => None, + } + } + + /// Handle pad press (Note On with velocity > 0). + fn handle_pad_press(&mut self, note: u8, velocity: u8) -> Option { + // Check for shift button + if note == Push2Mapping::BUTTON_SHIFT { + self.shift_held = true; + return None; + } + + // Translate pad press to command + if let Some(command) = Push2Mapping::translate_note_on(note, velocity, self.shift_held) { + return Some(ModuleEvent::DjCommand(command)); + } + + // Check for lighting cue triggers (Row 4: notes 60-67, Row 3: notes 52-59) + if (52..=67).contains(¬e) { + let cue_index = (note - 52) as usize; + return Some(ModuleEvent::DjCommand(ConsoleCommand::PlayCue { + list_index: 0, + cue_index, + })); + } + + // Check for fixture selection (Row 2: notes 44-51) + if (44..=51).contains(¬e) { + let fixture_id = (note - 44) as usize; + return Some(ModuleEvent::DjCommand(ConsoleCommand::AddSelectedFixture { + fixture_id, + })); + } + + // Lighting transport (Row 1: notes 36-43) + match note { + 40 => { + // GO button + return Some(ModuleEvent::DjCommand(ConsoleCommand::NextCue { + list_index: 0, + })); + } + 41 => { + // STOP button + return Some(ModuleEvent::DjCommand(ConsoleCommand::StopCue { + list_index: 0, + })); + } + 42 => { + // PREV button + return Some(ModuleEvent::DjCommand(ConsoleCommand::PrevCue { + list_index: 0, + })); + } + 43 => { + // NEXT cue list + return Some(ModuleEvent::DjCommand(ConsoleCommand::SelectNextCueList)); + } + _ => {} + } + + None + } + + /// Handle pad release (Note Off or Note On with velocity 0). + fn handle_pad_release(&mut self, note: u8) -> Option { + // Check for shift button release + if note == Push2Mapping::BUTTON_SHIFT { + self.shift_held = false; + return None; + } + + // Translate pad release to command (for CuePreview, etc.) + Push2Mapping::translate_note_off(note).map(ModuleEvent::DjCommand) + } + + /// Handle control change (encoders, faders). + fn handle_cc(&mut self, cc: u8, value: u8) -> Option { + Push2Mapping::translate_cc(cc, value).map(ModuleEvent::DjCommand) + } + + /// Process a single module event. Returns true if shutdown was received. + fn process_event(&mut self, event: &ModuleEvent) -> bool { + match event { + ModuleEvent::Shutdown => { + tracing::info!("Push 2 module received shutdown"); + return true; + } + + ModuleEvent::DjDeckStateChanged { + deck, + is_playing, + position_seconds, + bpm: _, + } => { + self.update_deck_state(*deck, *is_playing, *position_seconds); + } + + ModuleEvent::DjDeckLoaded { + deck, + title, + artist, + duration_seconds, + bpm, + .. + } => { + self.update_deck_loaded( + *deck, + title.clone(), + artist.clone(), + *duration_seconds, + *bpm, + ); + } + + ModuleEvent::DjRhythmSync { + bpm, beat_phase, .. + } => { + // Update BPM display for master deck + if self.deck_a.is_master { + self.deck_a.bpm = *bpm; + } else if self.deck_b.is_master { + self.deck_b.bpm = *bpm; + } + // Could pulse LEDs on beat here + let _ = beat_phase; + } + + ModuleEvent::DjWaveformLoaded { + deck, + samples, + frequency_bands, + duration_seconds, + } => { + let state = if *deck == 0 { + &mut self.deck_a + } else { + &mut self.deck_b + }; + + // Pre-compute waveform for fast rendering + // Target: ~100 samples per second for smooth scrolling + let target_samples_per_second = 100.0; + let target_sample_count = + (duration_seconds * target_samples_per_second).ceil() as usize; + let source_len = samples.len(); + + if source_len == 0 || *duration_seconds <= 0.0 { + state.waveform = DisplayWaveform::default(); + } else { + let mut amplitudes = Vec::with_capacity(target_sample_count); + let mut colors = Vec::with_capacity(target_sample_count); + + let has_bands = frequency_bands.is_some() + && frequency_bands.as_ref().unwrap().len() == source_len; + + for i in 0..target_sample_count { + // Map to source sample + let src_idx = + ((i as f64 / target_sample_count as f64) * source_len as f64) as usize; + let src_idx = src_idx.min(source_len - 1); + + let amp = samples[src_idx].abs().min(1.0); + amplitudes.push(amp); + + // Pre-compute color + let color = if has_bands { + let (low, mid, high) = frequency_bands.as_ref().unwrap()[src_idx]; + let max_band = low.max(mid).max(high); + if max_band > 0.01 { + let r = ((high / max_band) * 200.0 + 40.0) as u8; + let g = ((mid / max_band) * 200.0 + 40.0) as u8; + let b = ((low / max_band) * 200.0 + 60.0) as u8; + crate::display::FrameBuffer::rgb_to_bgr565(r, g, b) + } else { + 0x07FF // Cyan + } + } else { + 0x07FF // Cyan + }; + colors.push(color); + } + + state.waveform = DisplayWaveform { + amplitudes, + colors, + samples_per_second: target_samples_per_second, + }; + } + + tracing::debug!( + "Push 2: Pre-computed waveform for deck {} ({} -> {} samples)", + deck, + source_len, + state.waveform.amplitudes.len() + ); + } + + _ => {} + } + false + } + + /// Update deck display state from events. + fn update_deck_state(&mut self, deck: u8, is_playing: bool, position_seconds: f64) { + let state = if deck == 0 { + &mut self.deck_a + } else { + &mut self.deck_b + }; + state.is_playing = is_playing; + state.position_seconds = position_seconds; + state.last_update = std::time::Instant::now(); + + // Update LED state + let deck_id = if deck == 0 { DeckId::A } else { DeckId::B }; + self.led_state.update_transport(deck_id, is_playing); + } + + /// Get interpolated position for smooth display. + fn get_interpolated_position(state: &DeckDisplayState) -> f64 { + if !state.is_playing { + return state.position_seconds; + } + + let elapsed = state.last_update.elapsed().as_secs_f64(); + let interpolated = state.position_seconds + elapsed; + + // Clamp to track duration + interpolated.min(state.duration_seconds) + } + + /// Update deck loaded state. + fn update_deck_loaded( + &mut self, + deck: u8, + title: String, + artist: Option, + duration: f64, + bpm: Option, + ) { + let state = if deck == 0 { + &mut self.deck_a + } else { + &mut self.deck_b + }; + state.title = title; + state.artist = artist.unwrap_or_default(); + state.duration_seconds = duration; + state.bpm = bpm.unwrap_or(0.0); + } + + /// Render the display frame with interpolated positions. + fn render_display(&mut self) { + // Calculate interpolated positions for smooth rendering + let pos_a = Self::get_interpolated_position(&self.deck_a); + let pos_b = Self::get_interpolated_position(&self.deck_b); + + self.renderer.render_with_positions( + &mut self.frame_buffer, + &self.deck_a, + &self.deck_b, + pos_a, + pos_b, + ); + } + + /// Send display frame to Push 2. + fn send_display_frame(&mut self) -> Result<(), Box> { + if let Some(ref mut display) = self.display { + display.send_frame(&self.frame_buffer)?; + } + Ok(()) + } + + /// Send LED state to Push 2 via MIDI. + fn send_led_state(&mut self) { + if let Some(ref mut output) = self.midi_output { + for message in self.led_state.to_midi_messages() { + let _ = output.send(&message); + } + } + } +} + +impl Default for Push2Module { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl AsyncModule for Push2Module { + fn id(&self) -> ModuleId { + ModuleId::Push2 + } + + async fn initialize(&mut self) -> Result<(), Box> { + tracing::info!("Initializing Push 2 module"); + + // Try to connect display (non-fatal if it fails) + let _ = self.connect_display(); + + // Connect MIDI (required) + self.connect_midi()?; + + // Initialize LED state + self.send_led_state(); + + // Send initial display frame if connected + if self.display.is_some() { + self.render_display(); + let _ = self.send_display_frame(); + } + + self.status + .insert("state".to_string(), "initialized".to_string()); + Ok(()) + } + + async fn run( + &mut self, + mut rx: mpsc::Receiver, + tx: mpsc::Sender, + ) -> Result<(), Box> { + tracing::info!("Push 2 module running"); + self.status + .insert("state".to_string(), "running".to_string()); + + // Display refresh interval (~40fps for smooth waveform scrolling) + let mut display_interval = tokio::time::interval(Duration::from_millis(25)); + + // LED update interval (slower, ~10fps) + let mut led_interval = tokio::time::interval(Duration::from_millis(100)); + + // Take ownership of MIDI receiver + let mut midi_rx = self.midi_rx.take(); + + loop { + tokio::select! { + // Handle module events - drain all pending to stay current + Some(event) = rx.recv() => { + // Process first event + if self.process_event(&event) { + break; // Shutdown received + } + + // Drain all pending events to catch up to latest state + while let Ok(event) = rx.try_recv() { + if self.process_event(&event) { + break; // Shutdown received + } + } + } + + // Handle MIDI input + Some(message) = async { + if let Some(ref mut rx) = midi_rx { + rx.recv().await + } else { + std::future::pending().await + } + } => { + if let Some(event) = self.handle_midi_message(&message) { + // Use try_send to avoid blocking the event loop + // If the channel is full, the message is dropped (acceptable for MIDI) + if let Err(e) = tx.try_send(ModuleMessage::Event(event)) { + tracing::debug!("Failed to send MIDI event (channel full): {}", e); + } + } + } + + // Display refresh + _ = display_interval.tick() => { + if self.display.is_some() { + self.render_display(); + if let Err(e) = self.send_display_frame() { + tracing::warn!("Display update failed: {}", e); + } + } + } + + // LED refresh + _ = led_interval.tick() => { + self.send_led_state(); + } + } + } + + Ok(()) + } + + async fn shutdown(&mut self) -> Result<(), Box> { + tracing::info!("Shutting down Push 2 module"); + + // Clear display + if let Some(ref mut display) = self.display { + self.frame_buffer.clear(); + let _ = display.send_frame(&self.frame_buffer); + } + + // Turn off all LEDs + self.led_state.clear(); + self.send_led_state(); + + // Close connections (dropped automatically) + self.midi_input = None; + self.midi_output = None; + self.display = None; + + self.status + .insert("state".to_string(), "shutdown".to_string()); + Ok(()) + } + + fn status(&self) -> HashMap { + self.status.clone() + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index d1c00cd..ba3842c 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -14,3 +14,4 @@ parking_lot = "0.12.5" egui_plot = "0.34.0" rfd = "0.16.0" tokio = { version = "1.48.0", features = ["full"] } +log = "0.4" diff --git a/crates/ui/src/dj/deck.rs b/crates/ui/src/dj/deck.rs new file mode 100644 index 0000000..c374e9e --- /dev/null +++ b/crates/ui/src/dj/deck.rs @@ -0,0 +1,1453 @@ +//! Deck widget for DJ playback display and control. + +use std::sync::Arc; + +use eframe::egui::{self, Color32, Rect, Rounding, Stroke, Vec2}; +use halo_core::ConsoleCommand; +use tokio::sync::mpsc; + +use super::waveform_texture::WaveformTexture; +use super::TrackDragPayload; + +/// Waveform zoom levels (visible duration in seconds). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WaveformZoomLevel { + Overview, + Seconds16, + #[default] + Seconds8, + Seconds4, + Seconds2, + Seconds1, +} + +impl WaveformZoomLevel { + /// Get the visible duration in seconds for this zoom level. + /// Returns None for Overview mode (full track). + pub fn visible_duration(&self) -> Option { + match self { + Self::Overview => None, + Self::Seconds16 => Some(16.0), + Self::Seconds8 => Some(8.0), + Self::Seconds4 => Some(4.0), + Self::Seconds2 => Some(2.0), + Self::Seconds1 => Some(1.0), + } + } + + /// Get display label for UI. + pub fn label(&self) -> &'static str { + match self { + Self::Overview => "OVERVIEW", + Self::Seconds16 => "16s", + Self::Seconds8 => "8s", + Self::Seconds4 => "4s", + Self::Seconds2 => "2s", + Self::Seconds1 => "1s", + } + } + + /// Zoom in one level (returns self if already at max zoom). + pub fn zoom_in(&self) -> Self { + match self { + Self::Overview => Self::Seconds16, + Self::Seconds16 => Self::Seconds8, + Self::Seconds8 => Self::Seconds4, + Self::Seconds4 => Self::Seconds2, + Self::Seconds2 | Self::Seconds1 => Self::Seconds1, + } + } + + /// Zoom out one level (returns self if already at min zoom). + pub fn zoom_out(&self) -> Self { + match self { + Self::Overview | Self::Seconds16 => Self::Overview, + Self::Seconds8 => Self::Seconds16, + Self::Seconds4 => Self::Seconds8, + Self::Seconds2 => Self::Seconds4, + Self::Seconds1 => Self::Seconds2, + } + } + + /// Check if this is a zoomed view (not overview). + pub fn is_zoomed(&self) -> bool { + !matches!(self, Self::Overview) + } +} + +/// Visual state for a single deck. +pub struct DeckWidget { + /// Currently loaded track title. + pub track_title: Option, + /// Currently loaded track artist. + pub track_artist: Option, + /// Track duration in seconds. + pub duration_seconds: f64, + /// Current playback position in seconds. + pub position_seconds: f64, + /// Original BPM of the track. + pub original_bpm: f64, + /// Adjusted BPM (after pitch change). + pub adjusted_bpm: f64, + /// Pitch adjustment (-1.0 to 1.0). + pub pitch: f64, + /// Whether the deck is playing. + pub is_playing: bool, + /// Whether the deck is waiting for quantized sync start. + pub waiting_for_quantized_start: bool, + /// Whether this deck is the master. + pub is_master: bool, + /// Whether sync is enabled. + pub sync_enabled: bool, + /// Hot cue positions (4 slots). + pub hot_cues: [Option; 4], + /// Cue point position. + pub cue_point: Option, + /// Beat phase (0.0 to 1.0). + pub beat_phase: f64, + /// Waveform data for display (Arc for zero-copy sharing from state). + pub waveform: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. None for legacy waveforms without frequency analysis. + pub waveform_colors: Option>>, + /// Beat positions in seconds (from beat grid analysis). + pub beat_positions: Vec, + /// First beat offset in seconds. + pub first_beat_offset: f64, + /// Whether we are currently in cue preview mode (holding the cue button). + /// This is tracked explicitly rather than relying on egui's button state + /// because is_pointer_button_down_on() can lose track of the press. + pub cue_preview_active: bool, + /// Whether we've already handled the current cue button press. + /// This prevents non-preview actions from firing repeatedly. + cue_press_handled: bool, + /// Master Tempo (key lock) enabled. + pub master_tempo_enabled: bool, + /// Tempo range setting (0=±6%, 1=±10%, 2=±16%, 3=±25%, 4=±50%). + pub tempo_range: u8, + /// Current waveform zoom level (CDJ-style scrolling view). + pub waveform_zoom_level: WaveformZoomLevel, + // Loop state + /// Loop IN point in seconds. + pub loop_in: Option, + /// Loop OUT point in seconds. + pub loop_out: Option, + /// Whether loop is currently active. + pub loop_active: bool, + /// Number of beats in the current loop (supports 1/32 to 512 beats). + pub loop_beat_count: f64, + /// Cached GPU texture for waveform rendering. + waveform_texture: WaveformTexture, +} + +impl Default for DeckWidget { + fn default() -> Self { + Self { + track_title: None, + track_artist: None, + duration_seconds: 0.0, + position_seconds: 0.0, + original_bpm: 0.0, + adjusted_bpm: 0.0, + pitch: 0.0, + is_playing: false, + waiting_for_quantized_start: false, + is_master: false, + sync_enabled: false, + hot_cues: [None; 4], + cue_point: None, + beat_phase: 0.0, + waveform: Arc::new(Vec::new()), + waveform_colors: None, + beat_positions: Vec::new(), + first_beat_offset: 0.0, + cue_preview_active: false, + cue_press_handled: false, + master_tempo_enabled: false, + tempo_range: 1, + waveform_zoom_level: WaveformZoomLevel::default(), + loop_in: None, + loop_out: None, + loop_active: false, + loop_beat_count: 4.0, + waveform_texture: WaveformTexture::default(), + } + } +} + +impl DeckWidget { + /// Returns whether the cue button is currently being held (for repaint requests). + pub fn is_cue_held(&self) -> bool { + self.cue_preview_active + } + + /// Render the deck widget. + pub fn render( + &mut self, + ui: &mut egui::Ui, + deck_label: &str, + deck_number: u8, + console_tx: &mpsc::UnboundedSender, + ) { + let mut dropped_track_id: Option = None; + + // Get the rect we'll use for the deck + let available_rect = ui.available_rect_before_wrap(); + let deck_rect = Rect::from_min_size( + available_rect.min, + egui::vec2(available_rect.width(), 400.0), + ); + + // Check if something is being dragged + let is_dragging = ui.ctx().dragged_id().is_some(); + + // Check if pointer is over our deck rect + let pointer_over_deck = ui + .ctx() + .pointer_hover_pos() + .is_some_and(|pos| deck_rect.contains(pos)); + + // Draw the deck frame background + let fill_color = Color32::from_gray(25); + ui.painter() + .rect_filled(deck_rect, Rounding::same(8), fill_color); + + // Draw highlight border if dragging over this deck + if is_dragging && pointer_over_deck { + ui.painter().rect_stroke( + deck_rect, + 8.0, + Stroke::new(3.0, Color32::from_rgb(100, 200, 255)), + egui::StrokeKind::Outside, + ); + } + + // Check for drop: pointer was over deck and primary button just released + if pointer_over_deck && ui.input(|i| i.pointer.primary_released()) { + // Try to get the drag payload using the static DragAndDrop API + if let Some(payload) = egui::DragAndDrop::take_payload::(ui.ctx()) { + dropped_track_id = Some(payload.track_id); + } + } + + // Render deck contents inside the deck area + let content_rect = deck_rect.shrink(12.0); + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(content_rect) + .layout(egui::Layout::top_down(egui::Align::LEFT)), + ); + self.render_deck_contents(&mut content_ui, deck_label, deck_number, console_tx); + + // Consume the deck space + ui.allocate_rect(deck_rect, egui::Sense::hover()); + + // Send command if a track was dropped + if let Some(track_id) = dropped_track_id { + let _ = console_tx.send(ConsoleCommand::DjLoadTrack { + deck: deck_number, + track_id, + }); + } + } + + /// Render the internal deck contents. + fn render_deck_contents( + &mut self, + ui: &mut egui::Ui, + deck_label: &str, + deck_number: u8, + console_tx: &mpsc::UnboundedSender, + ) { + // Deck header with label and master indicator + ui.horizontal(|ui| { + ui.heading(format!("Deck {}", deck_label)); + if self.is_master { + ui.label( + egui::RichText::new("MASTER") + .color(Color32::from_rgb(255, 200, 0)) + .strong(), + ); + } + if self.sync_enabled { + ui.label( + egui::RichText::new("SYNC") + .color(Color32::from_rgb(0, 200, 255)) + .strong(), + ); + } + }); + + ui.separator(); + + // Track info + if let Some(title) = &self.track_title { + ui.label(egui::RichText::new(title).size(16.0).color(Color32::WHITE)); + if let Some(artist) = &self.track_artist { + ui.label(egui::RichText::new(artist).size(14.0).color(Color32::GRAY)); + } + } else { + ui.label( + egui::RichText::new("No track loaded") + .size(16.0) + .color(Color32::DARK_GRAY) + .italics(), + ); + } + + ui.add_space(8.0); + + // Waveform display with zoom controls + ui.horizontal(|ui| { + // Zoom out button + if ui + .add_enabled( + !matches!(self.waveform_zoom_level, WaveformZoomLevel::Overview), + egui::Button::new("-").min_size(Vec2::new(24.0, 20.0)), + ) + .on_hover_text("Zoom out") + .clicked() + { + self.waveform_zoom_level = self.waveform_zoom_level.zoom_out(); + } + + // Clickable zoom level label + let label_color = if self.waveform_zoom_level.is_zoomed() { + Color32::from_rgb(0, 200, 255) + } else { + Color32::GRAY + }; + if ui + .add( + egui::Button::new( + egui::RichText::new(self.waveform_zoom_level.label()) + .size(10.0) + .color(label_color), + ) + .min_size(Vec2::new(60.0, 20.0)), + ) + .on_hover_text("Toggle overview/zoom") + .clicked() + { + if self.waveform_zoom_level.is_zoomed() { + self.waveform_zoom_level = WaveformZoomLevel::Overview; + } else { + self.waveform_zoom_level = WaveformZoomLevel::Seconds8; + } + } + + // Zoom in button + if ui + .add_enabled( + !matches!(self.waveform_zoom_level, WaveformZoomLevel::Seconds1), + egui::Button::new("+").min_size(Vec2::new(24.0, 20.0)), + ) + .on_hover_text("Zoom in") + .clicked() + { + self.waveform_zoom_level = self.waveform_zoom_level.zoom_in(); + } + }); + + // Render the appropriate waveform view + if self.waveform_zoom_level.is_zoomed() { + self.render_zoomed_waveform(ui, deck_number, console_tx); + } else { + self.render_waveform(ui, deck_number, console_tx); + } + + ui.add_space(8.0); + + // Time and BPM display + ui.horizontal(|ui| { + // Time display + let position_str = format_time(self.position_seconds); + let duration_str = format_time(self.duration_seconds); + let remaining = self.duration_seconds - self.position_seconds; + let remaining_str = format!("-{}", format_time(remaining.max(0.0))); + + ui.label( + egui::RichText::new(&position_str) + .size(24.0) + .monospace() + .color(Color32::WHITE), + ); + ui.label( + egui::RichText::new(format!(" / {} ", duration_str)) + .size(14.0) + .monospace() + .color(Color32::GRAY), + ); + ui.label( + egui::RichText::new(&remaining_str) + .size(18.0) + .monospace() + .color(if remaining < 30.0 { + Color32::from_rgb(255, 100, 100) + } else { + Color32::GRAY + }), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // BPM display + ui.label( + egui::RichText::new(format!("{:.1} BPM", self.adjusted_bpm)) + .size(20.0) + .monospace() + .color(Color32::from_rgb(0, 255, 128)), + ); + }); + }); + + ui.add_space(8.0); + + // Transport controls + ui.horizontal(|ui| { + let button_size = Vec2::new(50.0, 40.0); + let small_button_size = Vec2::new(40.0, 40.0); + + // Play/Pause button + let (play_text, play_color) = if self.waiting_for_quantized_start { + ("SYNC", Color32::from_rgb(255, 200, 0)) // Yellow when waiting for sync + } else if self.is_playing { + ("||", Color32::from_rgb(0, 200, 100)) + } else { + (">", Color32::WHITE) + }; + if ui + .add_sized( + button_size, + egui::Button::new(egui::RichText::new(play_text).size(14.0).color(play_color)), + ) + .clicked() + { + if self.is_playing || self.waiting_for_quantized_start { + // Currently playing or waiting, send pause + let _ = console_tx.send(ConsoleCommand::DjPause { deck: deck_number }); + } else { + // Currently paused, send play + let _ = console_tx.send(ConsoleCommand::DjPlay { deck: deck_number }); + } + // State will be updated by DjDeckStateChanged event from the module + } + + // Cue button - Pioneer CDJ-style behavior: + // - When playing: click to jump to cue point and pause + // - When paused AT cue point: HOLD to preview from cue, release to return + // - When paused NOT at cue point: click to set new cue point + let cue_color = if self.cue_preview_active { + Color32::from_rgb(255, 100, 0) // Bright orange when previewing + } else if self.cue_point.is_some() { + Color32::from_rgb(255, 200, 0) + } else { + Color32::WHITE + }; + let cue_response = ui.add_sized( + button_size, + egui::Button::new(egui::RichText::new("CUE").size(14.0).color(cue_color)), + ); + + // Check global pointer state - this is more reliable than is_pointer_button_down_on() + // which can lose track of the press if the pointer moves slightly + let primary_down = ui.input(|i| i.pointer.primary_down()); + let at_cue = is_at_cue_point(self.position_seconds, self.cue_point); + + // Reset press handled flag when mouse is released + if !primary_down { + self.cue_press_handled = false; + } + + // Handle cue preview release - check FIRST before handling new presses + // Release when: we're in preview mode AND mouse button is released + if self.cue_preview_active && !primary_down { + self.cue_preview_active = false; + let _ = console_tx.send(ConsoleCommand::DjCuePreview { + deck: deck_number, + pressed: false, + }); + } + + // Handle new button press - detect press on this button + // but only when we haven't already handled this press + let button_pressed = cue_response.is_pointer_button_down_on(); + let should_handle = button_pressed && !self.cue_press_handled; + + if should_handle { + self.cue_press_handled = true; + + if self.is_playing { + // Playing: jump to cue point and pause + if let Some(cue_pos) = self.cue_point { + let _ = console_tx.send(ConsoleCommand::DjPause { deck: deck_number }); + let _ = console_tx.send(ConsoleCommand::DjSeek { + deck: deck_number, + position_seconds: cue_pos, + }); + } + } else if at_cue { + // Paused AT cue point: start preview (will be held) + // We track this ourselves and use global pointer state for release + self.cue_preview_active = true; + let _ = console_tx.send(ConsoleCommand::DjCuePreview { + deck: deck_number, + pressed: true, + }); + } else { + // Paused NOT at cue point (or no cue): set new cue point + let _ = console_tx.send(ConsoleCommand::DjSetCue { deck: deck_number }); + } + } + + ui.add_space(8.0); + + // Track search buttons (previous/next track) + if ui + .add_sized( + small_button_size, + egui::Button::new(egui::RichText::new("<<").size(16.0)), + ) + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjPreviousTrack { deck: deck_number }); + } + if ui + .add_sized( + small_button_size, + egui::Button::new(egui::RichText::new(">>").size(16.0)), + ) + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjNextTrack { deck: deck_number }); + } + + ui.add_space(8.0); + + // Sync button + let sync_color = if self.sync_enabled { + Color32::from_rgb(0, 200, 255) + } else { + Color32::GRAY + }; + if ui + .add_sized( + button_size, + egui::Button::new(egui::RichText::new("SYNC").size(12.0).color(sync_color)), + ) + .clicked() + { + self.sync_enabled = !self.sync_enabled; + let _ = console_tx.send(ConsoleCommand::DjToggleSync { deck: deck_number }); + } + + // Master button + let master_color = if self.is_master { + Color32::from_rgb(255, 200, 0) + } else { + Color32::GRAY + }; + if ui + .add_sized( + button_size, + egui::Button::new(egui::RichText::new("MST").size(12.0).color(master_color)), + ) + .clicked() + { + self.is_master = !self.is_master; + let _ = console_tx.send(ConsoleCommand::DjSetMaster { deck: deck_number }); + } + + ui.add_space(12.0); + + // Master Tempo button (key lock) - magenta when active like CDJ-3000 + let mt_color = if self.master_tempo_enabled { + Color32::from_rgb(255, 0, 200) // Magenta + } else { + Color32::GRAY + }; + if ui + .add_sized( + Vec2::new(60.0, 30.0), + egui::Button::new(egui::RichText::new("M.TEMPO").size(11.0).color(mt_color)), + ) + .on_hover_text("Master Tempo - tempo changes without pitch change") + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjToggleMasterTempo { deck: deck_number }); + } + + // Tempo range selector + let range_labels = ["±6%", "±10%", "±16%", "Wide"]; + let current_label = range_labels + .get(self.tempo_range as usize) + .unwrap_or(&"±10%"); + egui::ComboBox::from_id_salt(format!("tempo_range_{}", deck_number)) + .width(50.0) + .selected_text(*current_label) + .show_ui(ui, |ui| { + for (i, label) in range_labels.iter().enumerate() { + if ui + .selectable_value(&mut self.tempo_range, i as u8, *label) + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjSetTempoRange { + deck: deck_number, + range: i as u8, + }); + } + } + }); + }); + + ui.add_space(8.0); + + // Hot cue buttons + ui.horizontal(|ui| { + ui.label("Hot Cues:"); + for i in 0..4 { + let has_cue = self.hot_cues[i].is_some(); + let color = if has_cue { + hot_cue_color(i) + } else { + Color32::DARK_GRAY + }; + if ui + .add_sized( + Vec2::new(40.0, 30.0), + egui::Button::new( + egui::RichText::new(format!("{}", i + 1)).size(16.0).color( + if has_cue { + Color32::BLACK + } else { + Color32::GRAY + }, + ), + ) + .fill(color), + ) + .clicked() + { + if has_cue { + // Jump to hot cue + } else { + // Set hot cue + self.hot_cues[i] = Some(self.position_seconds); + } + } + } + }); + + ui.add_space(8.0); + + // Loop controls (CDJ-3000 style) + ui.horizontal(|ui| { + ui.label("Loop:"); + + let loop_button_size = Vec2::new(35.0, 30.0); + + // 4-beat loop button - green when active with 4 beats + let loop_4_active = self.loop_active && (self.loop_beat_count - 4.0).abs() < 0.001; + let loop_4_color = if loop_4_active { + Color32::from_rgb(0, 200, 100) // Green + } else { + Color32::DARK_GRAY + }; + if ui + .add_sized( + loop_button_size, + egui::Button::new(egui::RichText::new("4").size(14.0).color( + if loop_4_active { + Color32::BLACK + } else { + Color32::WHITE + }, + )) + .fill(loop_4_color), + ) + .on_hover_text("Set 4-beat loop") + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjSetLoop { + deck: deck_number, + beat_count: 4, + }); + } + + // 8-beat loop button - green when active with 8 beats + let loop_8_active = self.loop_active && (self.loop_beat_count - 8.0).abs() < 0.001; + let loop_8_color = if loop_8_active { + Color32::from_rgb(0, 200, 100) // Green + } else { + Color32::DARK_GRAY + }; + if ui + .add_sized( + loop_button_size, + egui::Button::new(egui::RichText::new("8").size(14.0).color( + if loop_8_active { + Color32::BLACK + } else { + Color32::WHITE + }, + )) + .fill(loop_8_color), + ) + .on_hover_text("Set 8-beat loop") + .clicked() + { + let _ = console_tx.send(ConsoleCommand::DjSetLoop { + deck: deck_number, + beat_count: 8, + }); + } + + ui.add_space(4.0); + + // Reloop/Exit button + let has_loop = self.loop_in.is_some(); + let (exit_text, exit_color) = if self.loop_active { + ("EXIT", Color32::from_rgb(255, 140, 0)) // Orange when active + } else if has_loop { + ("RELOOP", Color32::from_rgb(0, 150, 255)) // Blue when loop defined but inactive + } else { + ("RELOOP", Color32::DARK_GRAY) // Gray when no loop defined + }; + + if ui + .add_sized( + Vec2::new(55.0, 30.0), + egui::Button::new(egui::RichText::new(exit_text).size(11.0).color( + if self.loop_active || has_loop { + Color32::BLACK + } else { + Color32::GRAY + }, + )) + .fill(exit_color), + ) + .on_hover_text(if self.loop_active { + "Exit loop" + } else { + "Re-enable loop" + }) + .clicked() + { + if has_loop { + let _ = console_tx.send(ConsoleCommand::DjToggleLoop { deck: deck_number }); + } + } + + ui.add_space(8.0); + + // Beat jump / Loop halve-double buttons + let jump_button_size = Vec2::new(35.0, 30.0); + + // Left button: Beat jump back OR halve loop + let left_text = if self.loop_active { "/2" } else { "<<" }; + let left_tooltip = if self.loop_active { + "Halve loop" + } else { + "Jump back 4 beats" + }; + if ui + .add_sized( + jump_button_size, + egui::Button::new(egui::RichText::new(left_text).size(14.0)), + ) + .on_hover_text(left_tooltip) + .clicked() + { + if self.loop_active { + let _ = console_tx.send(ConsoleCommand::DjHalveLoop { deck: deck_number }); + } else { + let _ = console_tx.send(ConsoleCommand::DjSeekBeats { + deck: deck_number, + beats: -4, + }); + } + } + + // Right button: Beat jump forward OR double loop + let right_text = if self.loop_active { "x2" } else { ">>" }; + let right_tooltip = if self.loop_active { + "Double loop" + } else { + "Jump forward 4 beats" + }; + if ui + .add_sized( + jump_button_size, + egui::Button::new(egui::RichText::new(right_text).size(14.0)), + ) + .on_hover_text(right_tooltip) + .clicked() + { + if self.loop_active { + let _ = console_tx.send(ConsoleCommand::DjDoubleLoop { deck: deck_number }); + } else { + let _ = console_tx.send(ConsoleCommand::DjSeekBeats { + deck: deck_number, + beats: 4, + }); + } + } + }); + + ui.add_space(8.0); + + // Beat Grid Editor + egui::CollapsingHeader::new("Beat Grid") + .id_salt(format!("beat_grid_{}", deck_label)) + .show(ui, |ui| { + // First row: Set Downbeat and Beat Shift + ui.horizontal(|ui| { + if ui.button("Set Downbeat").clicked() { + let _ = + console_tx.send(ConsoleCommand::DjSetDownbeat { deck: deck_number }); + } + ui.separator(); + if ui.button("◀ Beat").clicked() { + let _ = console_tx.send(ConsoleCommand::DjShiftBeatGrid { + deck: deck_number, + beats: -1, + }); + } + if ui.button("Beat ▶").clicked() { + let _ = console_tx.send(ConsoleCommand::DjShiftBeatGrid { + deck: deck_number, + beats: 1, + }); + } + }); + + // Second row: Fine nudge controls + ui.horizontal(|ui| { + ui.label(format!("Offset: {:.1}ms", self.first_beat_offset * 1000.0)); + ui.separator(); + + let nudge_size = Vec2::new(45.0, 24.0); + if ui.add_sized(nudge_size, egui::Button::new("-10")).clicked() { + let _ = console_tx.send(ConsoleCommand::DjNudgeBeatGrid { + deck: deck_number, + offset_ms: -10.0, + }); + } + if ui.add_sized(nudge_size, egui::Button::new("-1")).clicked() { + let _ = console_tx.send(ConsoleCommand::DjNudgeBeatGrid { + deck: deck_number, + offset_ms: -1.0, + }); + } + if ui.add_sized(nudge_size, egui::Button::new("+1")).clicked() { + let _ = console_tx.send(ConsoleCommand::DjNudgeBeatGrid { + deck: deck_number, + offset_ms: 1.0, + }); + } + if ui.add_sized(nudge_size, egui::Button::new("+10")).clicked() { + let _ = console_tx.send(ConsoleCommand::DjNudgeBeatGrid { + deck: deck_number, + offset_ms: 10.0, + }); + } + }); + }); + + ui.add_space(8.0); + + // Pitch fader + ui.horizontal(|ui| { + ui.label("Pitch:"); + let pitch_percent = self.pitch * 100.0; + let slider_response = ui.add( + egui::Slider::new(&mut self.pitch, -0.5..=0.5) + .show_value(false) + .trailing_fill(true), + ); + // Double-click to reset pitch to 0% + if slider_response.double_clicked() { + self.pitch = 0.0; + let _ = console_tx.send(ConsoleCommand::DjSetPitch { + deck: deck_number, + percent: 0.0, + }); + } else if slider_response.changed() { + // Send pitch change command when slider is dragged + let _ = console_tx.send(ConsoleCommand::DjSetPitch { + deck: deck_number, + percent: self.pitch, // Decimal value: -0.5 = -50% + }); + } + ui.label( + egui::RichText::new(format!("{:+.1}%", pitch_percent)) + .monospace() + .color(if self.pitch.abs() > 0.01 { + Color32::from_rgb(255, 200, 0) + } else { + Color32::GRAY + }), + ); + }); + } + + /// Render the waveform display. + fn render_waveform( + &mut self, + ui: &mut egui::Ui, + deck_number: u8, + console_tx: &mpsc::UnboundedSender, + ) { + let available_width = ui.available_width(); + let height = 60.0; + let (rect, response) = + ui.allocate_exact_size(Vec2::new(available_width, height), egui::Sense::click()); + + // Handle needle drop (click to seek) + if response.clicked() { + if let Some(pointer_pos) = response.interact_pointer_pos() { + let x_offset = pointer_pos.x - rect.left(); + let progress = (x_offset / available_width).clamp(0.0, 1.0); + let position_seconds = progress as f64 * self.duration_seconds; + let _ = console_tx.send(ConsoleCommand::DjSeek { + deck: deck_number, + position_seconds, + }); + } + } + + // Handle scroll wheel zoom (scroll up to zoom in from overview) + if response.hovered() { + let scroll_delta = ui.input(|i| i.raw_scroll_delta.y); + if scroll_delta > 0.0 { + self.waveform_zoom_level = self.waveform_zoom_level.zoom_in(); + } + } + + let painter = ui.painter_at(rect); + + // Background + painter.rect_filled(rect, Rounding::same(4), Color32::from_gray(15)); + + // GPU texture-based waveform rendering (update once, draw instantly) + if !self.waveform.is_empty() { + // Update texture only when waveform data changes + // Use high resolution (up to 8000px) for crisp display in both overview and zoomed + // views + if self + .waveform_texture + .needs_update(&self.waveform, &self.waveform_colors) + { + let texture_width = self.waveform.len().min(8000); + self.waveform_texture.update( + ui.ctx(), + &self.waveform, + &self.waveform_colors, + texture_width, + ); + } + + // Draw the pre-rendered texture (O(1) CPU work) + self.waveform_texture.draw_overview(ui, rect); + } else { + // Empty waveform placeholder + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "No waveform", + egui::FontId::proportional(12.0), + Color32::DARK_GRAY, + ); + } + + // Draw beat grid markers (batched for performance) + if self.duration_seconds > 0.0 && !self.beat_positions.is_empty() { + let beat_interval = if self.adjusted_bpm > 0.0 { + 60.0 / self.adjusted_bpm + } else { + 0.5 // Default if BPM unknown + }; + + let mut beat_shapes: Vec = Vec::with_capacity(self.beat_positions.len()); + + for (idx, beat_pos) in self.beat_positions.iter().enumerate() { + if *beat_pos >= 0.0 && *beat_pos <= self.duration_seconds { + let x = rect.left() + + ((*beat_pos / self.duration_seconds) as f32 * available_width); + + // Check if downbeat (every 4 beats) for stronger visual + let beats_from_first = if beat_interval > 0.0 { + ((beat_pos - self.first_beat_offset) / beat_interval).round() as usize + } else { + idx + }; + let is_downbeat = beats_from_first % 4 == 0; + + let color = if is_downbeat { + // Brighter for downbeats + Color32::from_rgba_unmultiplied(255, 255, 255, 100) + } else { + // Subtle for regular beats + Color32::from_rgba_unmultiplied(255, 255, 255, 40) + }; + + beat_shapes.push(egui::Shape::line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + Stroke::new(1.0, color), + )); + } + } + + painter.extend(beat_shapes); + } + + // Playhead position + if self.duration_seconds > 0.0 { + let progress = (self.position_seconds / self.duration_seconds) as f32; + let playhead_x = rect.left() + (progress * available_width); + painter.line_segment( + [ + egui::pos2(playhead_x, rect.top()), + egui::pos2(playhead_x, rect.bottom()), + ], + Stroke::new(2.0, Color32::WHITE), + ); + } + + // Shadow playhead (hover preview) + if response.hovered() { + if let Some(hover_pos) = response.hover_pos() { + let hover_x = hover_pos.x.clamp(rect.left(), rect.right()); + painter.line_segment( + [ + egui::pos2(hover_x, rect.top()), + egui::pos2(hover_x, rect.bottom()), + ], + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 80)), + ); + } + } + + // Cue point marker + if let Some(cue_pos) = self.cue_point { + if self.duration_seconds > 0.0 { + let cue_x = + rect.left() + ((cue_pos / self.duration_seconds) as f32 * available_width); + painter.line_segment( + [ + egui::pos2(cue_x, rect.top()), + egui::pos2(cue_x, rect.bottom()), + ], + Stroke::new(2.0, Color32::from_rgb(255, 200, 0)), + ); + } + } + + // Hot cue markers + for (i, hot_cue) in self.hot_cues.iter().enumerate() { + if let Some(pos) = hot_cue { + if self.duration_seconds > 0.0 { + let x = rect.left() + ((*pos / self.duration_seconds) as f32 * available_width); + let marker_rect = Rect::from_center_size( + egui::pos2(x, rect.top() + 5.0), + Vec2::new(8.0, 10.0), + ); + painter.rect_filled(marker_rect, Rounding::same(2), hot_cue_color(i)); + } + } + } + + // Loop region overlay + if let (Some(loop_in), Some(loop_out)) = (self.loop_in, self.loop_out) { + if self.duration_seconds > 0.0 { + let start_x = + rect.left() + ((loop_in / self.duration_seconds) as f32 * available_width); + let end_x = + rect.left() + ((loop_out / self.duration_seconds) as f32 * available_width); + + // Semi-transparent fill + let fill_color = if self.loop_active { + Color32::from_rgba_unmultiplied(0, 200, 100, 40) // Green tint when active + } else { + Color32::from_rgba_unmultiplied(100, 100, 100, 30) // Gray tint when inactive + }; + let loop_rect = Rect::from_x_y_ranges(start_x..=end_x, rect.top()..=rect.bottom()); + painter.rect_filled(loop_rect, 0.0, fill_color); + + // IN/OUT boundary lines + let line_color = if self.loop_active { + Color32::from_rgb(0, 255, 128) // Green + } else { + Color32::from_rgb(100, 150, 255) // Blue + }; + painter.line_segment( + [ + egui::pos2(start_x, rect.top()), + egui::pos2(start_x, rect.bottom()), + ], + Stroke::new(2.0, line_color), + ); + painter.line_segment( + [ + egui::pos2(end_x, rect.top()), + egui::pos2(end_x, rect.bottom()), + ], + Stroke::new(2.0, line_color), + ); + } + } + + // Beat phase indicator + if self.is_playing { + let beat_indicator_width = 4.0; + let beat_x = rect.right() - 10.0 - (self.beat_phase as f32 * 20.0); + let beat_rect = Rect::from_center_size( + egui::pos2(beat_x, rect.bottom() - 5.0), + Vec2::new(beat_indicator_width, 6.0), + ); + painter.rect_filled(beat_rect, Rounding::same(1), Color32::from_rgb(0, 255, 128)); + } + } + + /// Render the zoomed waveform display (CDJ-style scrolling view). + /// + /// Shows approximately 8 seconds of audio with the playhead fixed at 1/3 from left. + /// The waveform scrolls as the track plays, giving a "driving" feel like a CDJ-3000. + fn render_zoomed_waveform( + &mut self, + ui: &mut egui::Ui, + deck_number: u8, + console_tx: &mpsc::UnboundedSender, + ) { + let available_width = ui.available_width(); + let height = 80.0; // Taller for zoomed view + let (rect, response) = + ui.allocate_exact_size(Vec2::new(available_width, height), egui::Sense::click()); + + let painter = ui.painter_at(rect); + + // Background + painter.rect_filled(rect, Rounding::same(4), Color32::from_gray(10)); + + // Zoomed view parameters + let zoom_window_seconds = self.waveform_zoom_level.visible_duration().unwrap_or(8.0); + let playhead_position = 0.33; // Playhead at 1/3 from left (like CDJ-3000) + + // Calculate the time window to display + let window_start = self.position_seconds - (zoom_window_seconds * playhead_position); + let window_end = window_start + zoom_window_seconds; + + // Handle click to seek within visible window + if response.clicked() { + if let Some(pointer_pos) = response.interact_pointer_pos() { + let x_offset = pointer_pos.x - rect.left(); + let click_progress = x_offset / available_width; + let click_time = window_start + (click_progress as f64 * zoom_window_seconds); + let position_seconds = click_time.clamp(0.0, self.duration_seconds); + let _ = console_tx.send(ConsoleCommand::DjSeek { + deck: deck_number, + position_seconds, + }); + } + } + + // Handle scroll wheel zoom + if response.hovered() { + let scroll_delta = ui.input(|i| i.raw_scroll_delta.y); + if scroll_delta > 0.0 { + self.waveform_zoom_level = self.waveform_zoom_level.zoom_in(); + } else if scroll_delta < 0.0 { + self.waveform_zoom_level = self.waveform_zoom_level.zoom_out(); + } + } + + // GPU texture-based waveform rendering (update once, scroll via UV coords) + if !self.waveform.is_empty() && self.duration_seconds > 0.0 { + // Update texture only when waveform data changes + // Use same high resolution as overview (8000px) so texture is shared between views + if self + .waveform_texture + .needs_update(&self.waveform, &self.waveform_colors) + { + let texture_width = self.waveform.len().min(8000); + self.waveform_texture.update( + ui.ctx(), + &self.waveform, + &self.waveform_colors, + texture_width, + ); + } + + // Draw zoomed portion of texture using UV offset (O(1) CPU work) + self.waveform_texture.draw_zoomed( + ui, + rect, + self.position_seconds, + self.duration_seconds, + zoom_window_seconds, + playhead_position as f32, + ); + } else { + // Empty waveform placeholder + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "No waveform", + egui::FontId::proportional(12.0), + Color32::DARK_GRAY, + ); + } + + // Draw beat grid markers (batched, only those in visible window) + if self.duration_seconds > 0.0 && !self.beat_positions.is_empty() { + let beat_interval = if self.adjusted_bpm > 0.0 { + 60.0 / self.adjusted_bpm + } else { + 0.5 + }; + + // Estimate visible beats for capacity (roughly 2 beats/sec at 120bpm) + let estimated_visible_beats = + (zoom_window_seconds * self.adjusted_bpm / 60.0).ceil() as usize + 2; + let mut beat_shapes: Vec = Vec::with_capacity(estimated_visible_beats); + + for (idx, beat_pos) in self.beat_positions.iter().enumerate() { + // Only draw beats within visible window + if *beat_pos >= window_start && *beat_pos <= window_end { + let x_progress = (beat_pos - window_start) / zoom_window_seconds; + let x = rect.left() + (x_progress as f32 * available_width); + + // Check if downbeat (every 4 beats) + let beats_from_first = if beat_interval > 0.0 { + ((beat_pos - self.first_beat_offset) / beat_interval).round() as usize + } else { + idx + }; + let is_downbeat = beats_from_first % 4 == 0; + + let color = if is_downbeat { + Color32::from_rgba_unmultiplied(255, 255, 255, 120) + } else { + Color32::from_rgba_unmultiplied(255, 255, 255, 50) + }; + + beat_shapes.push(egui::Shape::line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + Stroke::new(if is_downbeat { 2.0 } else { 1.0 }, color), + )); + } + } + + painter.extend(beat_shapes); + } + + // Loop region overlay (only if visible in window) + if let (Some(loop_in), Some(loop_out)) = (self.loop_in, self.loop_out) { + // Check if loop region overlaps with visible window + if loop_out >= window_start && loop_in <= window_end { + // Clamp loop bounds to visible window + let visible_start = loop_in.max(window_start); + let visible_end = loop_out.min(window_end); + + let start_x_progress = (visible_start - window_start) / zoom_window_seconds; + let end_x_progress = (visible_end - window_start) / zoom_window_seconds; + + let start_x = rect.left() + (start_x_progress as f32 * available_width); + let end_x = rect.left() + (end_x_progress as f32 * available_width); + + // Semi-transparent fill + let fill_color = if self.loop_active { + Color32::from_rgba_unmultiplied(0, 200, 100, 50) // Green tint when active + } else { + Color32::from_rgba_unmultiplied(100, 100, 100, 35) // Gray tint when inactive + }; + let loop_rect = Rect::from_x_y_ranges(start_x..=end_x, rect.top()..=rect.bottom()); + painter.rect_filled(loop_rect, 0.0, fill_color); + + // Draw IN boundary line if visible + let line_color = if self.loop_active { + Color32::from_rgb(0, 255, 128) // Green + } else { + Color32::from_rgb(100, 150, 255) // Blue + }; + + if loop_in >= window_start && loop_in <= window_end { + let in_x_progress = (loop_in - window_start) / zoom_window_seconds; + let in_x = rect.left() + (in_x_progress as f32 * available_width); + painter.line_segment( + [ + egui::pos2(in_x, rect.top()), + egui::pos2(in_x, rect.bottom()), + ], + Stroke::new(2.0, line_color), + ); + // "IN" label + painter.text( + egui::pos2(in_x + 3.0, rect.top() + 10.0), + egui::Align2::LEFT_CENTER, + "IN", + egui::FontId::proportional(9.0), + line_color, + ); + } + + // Draw OUT boundary line if visible + if loop_out >= window_start && loop_out <= window_end { + let out_x_progress = (loop_out - window_start) / zoom_window_seconds; + let out_x = rect.left() + (out_x_progress as f32 * available_width); + painter.line_segment( + [ + egui::pos2(out_x, rect.top()), + egui::pos2(out_x, rect.bottom()), + ], + Stroke::new(2.0, line_color), + ); + // "OUT" label + painter.text( + egui::pos2(out_x - 3.0, rect.top() + 10.0), + egui::Align2::RIGHT_CENTER, + "OUT", + egui::FontId::proportional(9.0), + line_color, + ); + } + } + } + + // Fixed playhead position (the track scrolls, playhead stays fixed) + let playhead_x = rect.left() + (playhead_position as f32 * available_width); + + // Draw playhead glow + painter.line_segment( + [ + egui::pos2(playhead_x, rect.top()), + egui::pos2(playhead_x, rect.bottom()), + ], + Stroke::new(4.0, Color32::from_rgba_unmultiplied(255, 255, 255, 40)), + ); + painter.line_segment( + [ + egui::pos2(playhead_x, rect.top()), + egui::pos2(playhead_x, rect.bottom()), + ], + Stroke::new(2.0, Color32::WHITE), + ); + + // Draw cue point marker if in visible window + if let Some(cue_pos) = self.cue_point { + if cue_pos >= window_start && cue_pos <= window_end { + let x_progress = (cue_pos - window_start) / zoom_window_seconds; + let cue_x = rect.left() + (x_progress as f32 * available_width); + painter.line_segment( + [ + egui::pos2(cue_x, rect.top()), + egui::pos2(cue_x, rect.bottom()), + ], + Stroke::new(2.0, Color32::from_rgb(255, 200, 0)), + ); + } + } + + // Draw hot cue markers if in visible window + for (i, hot_cue) in self.hot_cues.iter().enumerate() { + if let Some(pos) = hot_cue { + if *pos >= window_start && *pos <= window_end { + let x_progress = (pos - window_start) / zoom_window_seconds; + let x = rect.left() + (x_progress as f32 * available_width); + let marker_rect = Rect::from_center_size( + egui::pos2(x, rect.top() + 8.0), + Vec2::new(12.0, 14.0), + ); + painter.rect_filled(marker_rect, Rounding::same(2), hot_cue_color(i)); + // Draw hot cue number + painter.text( + marker_rect.center(), + egui::Align2::CENTER_CENTER, + format!("{}", i + 1), + egui::FontId::proportional(9.0), + Color32::WHITE, + ); + } + } + } + + // Draw time markers at the edges + let start_time = window_start.max(0.0); + let end_time = window_end.min(self.duration_seconds); + + painter.text( + egui::pos2(rect.left() + 4.0, rect.bottom() - 12.0), + egui::Align2::LEFT_CENTER, + format_time(start_time), + egui::FontId::monospace(10.0), + Color32::from_rgba_unmultiplied(255, 255, 255, 150), + ); + + painter.text( + egui::pos2(rect.right() - 4.0, rect.bottom() - 12.0), + egui::Align2::RIGHT_CENTER, + format_time(end_time), + egui::FontId::monospace(10.0), + Color32::from_rgba_unmultiplied(255, 255, 255, 150), + ); + + // Shadow playhead (hover preview) - shows where you'll seek on click + if response.hovered() { + if let Some(hover_pos) = response.hover_pos() { + let hover_x = hover_pos.x.clamp(rect.left(), rect.right()); + painter.line_segment( + [ + egui::pos2(hover_x, rect.top()), + egui::pos2(hover_x, rect.bottom()), + ], + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 80)), + ); + + // Show time at hover position + let hover_progress = (hover_x - rect.left()) / available_width; + let hover_time = window_start + (hover_progress as f64 * zoom_window_seconds); + if hover_time >= 0.0 && hover_time <= self.duration_seconds { + painter.text( + egui::pos2(hover_x, rect.top() + 10.0), + egui::Align2::CENTER_CENTER, + format_time(hover_time), + egui::FontId::monospace(9.0), + Color32::from_rgba_unmultiplied(255, 255, 255, 200), + ); + } + } + } + } +} + +/// Format seconds as MM:SS.ss (handles negative values for countdown display). +fn format_time(seconds: f64) -> String { + if seconds < 0.0 { + let abs_seconds = seconds.abs(); + let mins = (abs_seconds / 60.0).floor() as u32; + let secs = abs_seconds % 60.0; + format!("-{:02}:{:05.2}", mins, secs) + } else { + let mins = (seconds / 60.0).floor() as u32; + let secs = seconds % 60.0; + format!("{:02}:{:05.2}", mins, secs) + } +} + +/// Get color for a hot cue slot. +fn hot_cue_color(slot: usize) -> Color32 { + match slot { + 0 => Color32::from_rgb(255, 100, 100), // Red + 1 => Color32::from_rgb(100, 255, 100), // Green + 2 => Color32::from_rgb(100, 100, 255), // Blue + 3 => Color32::from_rgb(255, 255, 100), // Yellow + _ => Color32::GRAY, + } +} + +/// Check if playhead position is approximately at the cue point. +fn is_at_cue_point(position: f64, cue_point: Option) -> bool { + match cue_point { + Some(cue) => (position - cue).abs() < 0.1, // 100ms tolerance + None => false, + } +} diff --git a/crates/ui/src/dj/library.rs b/crates/ui/src/dj/library.rs new file mode 100644 index 0000000..8df1f2c --- /dev/null +++ b/crates/ui/src/dj/library.rs @@ -0,0 +1,616 @@ +//! Library browser for DJ track selection. + +use eframe::egui::{self, Color32, RichText, Rounding, Vec2}; +use halo_core::ConsoleCommand; +use log; +use tokio::sync::mpsc; + +/// Drag payload for a track being dragged from the library. +#[derive(Clone, Debug)] +pub struct TrackDragPayload { + /// The track ID being dragged. + pub track_id: i64, + /// Track title for display during drag. + pub title: String, +} + +/// A track entry in the library. +#[derive(Clone)] +pub struct TrackEntry { + /// Track ID from database. + pub id: i64, + /// Track title. + pub title: String, + /// Track artist. + pub artist: Option, + /// Duration in seconds. + pub duration_seconds: f64, + /// BPM (if analyzed). + pub bpm: Option, + /// File path on disk. + pub file_path: String, +} + +/// Library browser state. +#[derive(Default)] +pub struct LibraryBrowser { + /// Search query. + search_query: String, + /// Currently selected track index. + selected_index: Option, + /// List of tracks (populated from database). + tracks: Vec, + /// Sort column. + sort_column: SortColumn, + /// Sort ascending. + sort_ascending: bool, + /// Track ID being edited for BPM (when dialog is open). + editing_bpm_track_id: Option, + /// BPM value being edited. + bpm_edit_value: String, +} + +/// Column to sort by. +#[derive(Default, Clone, Copy, PartialEq)] +enum SortColumn { + #[default] + Title, + Artist, + Bpm, + Duration, +} + +impl LibraryBrowser { + /// Render the library browser. + pub fn render( + &mut self, + ui: &mut egui::Ui, + console_tx: &mpsc::UnboundedSender, + ) { + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.search_query) + .desired_width(ui.available_width() - 60.0) + .hint_text("Search tracks..."), + ); + if response.changed() { + // Filter tracks based on search + self.filter_tracks(); + } + if ui.button("Clear").clicked() { + self.search_query.clear(); + self.filter_tracks(); + } + }); + + ui.add_space(8.0); + + // Column headers + ui.horizontal(|ui| { + let header_style = RichText::new("").size(12.0).color(Color32::GRAY); + + if ui + .selectable_label( + self.sort_column == SortColumn::Title, + RichText::new("Title").size(12.0).color(Color32::GRAY), + ) + .clicked() + { + self.toggle_sort(SortColumn::Title); + } + + ui.add_space(80.0); + + if ui + .selectable_label( + self.sort_column == SortColumn::Artist, + RichText::new("Artist").size(12.0).color(Color32::GRAY), + ) + .clicked() + { + self.toggle_sort(SortColumn::Artist); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .selectable_label( + self.sort_column == SortColumn::Duration, + RichText::new("Time").size(12.0).color(Color32::GRAY), + ) + .clicked() + { + self.toggle_sort(SortColumn::Duration); + } + + ui.add_space(20.0); + + if ui + .selectable_label( + self.sort_column == SortColumn::Bpm, + RichText::new("BPM").size(12.0).color(Color32::GRAY), + ) + .clicked() + { + self.toggle_sort(SortColumn::Bpm); + } + }); + }); + + ui.separator(); + + // Track list + let mut double_clicked_track_id: Option = None; + let mut context_reanalyze_track_id: Option = None; + let mut context_edit_bpm_track: Option<(i64, f64)> = None; + let mut context_delete_track_id: Option = None; + let mut context_show_in_finder_path: Option = None; + + // Reserve space for bottom controls (buttons + spacing) + let bottom_height = 40.0; + let available_height = ui.available_height() - bottom_height; + + egui::ScrollArea::vertical() + .max_height(available_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + if self.tracks.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.label( + RichText::new("No tracks in library") + .size(14.0) + .color(Color32::DARK_GRAY) + .italics(), + ); + ui.add_space(8.0); + ui.label( + RichText::new("Import tracks using File > Import Music Folder") + .size(12.0) + .color(Color32::DARK_GRAY), + ); + }); + } else { + // Clone filtered tracks to avoid borrow issues + let filtered_tracks: Vec = self + .get_filtered_tracks() + .iter() + .map(|t| (*t).clone()) + .collect(); + let current_selected = self.selected_index; + let mut new_selected = current_selected; + + for (idx, track) in filtered_tracks.iter().enumerate() { + let is_selected = current_selected == Some(idx); + let track_id = track.id; + let track_title = track.title.clone(); + + let fill_color = if is_selected { + Color32::from_rgb(60, 80, 120) + } else if idx % 2 == 0 { + Color32::from_gray(30) + } else { + Color32::from_gray(25) + }; + + // Create drag payload + let payload = TrackDragPayload { + track_id, + title: track_title.clone(), + }; + + // Allocate space for the row first + let desired_size = Vec2::new(ui.available_width(), 40.0); + let (rect, base_response) = + ui.allocate_exact_size(desired_size, egui::Sense::click_and_drag()); + + // Paint background + if ui.is_rect_visible(rect) { + let visuals = if base_response.hovered() { + Color32::from_rgb(70, 90, 130) + } else { + fill_color + }; + ui.painter().rect_filled(rect, Rounding::same(2), visuals); + + let text_rect = rect.shrink2(Vec2::new(8.0, 4.0)); + + // Title + ui.painter().text( + text_rect.left_top(), + egui::Align2::LEFT_TOP, + &track.title, + egui::FontId::proportional(13.0), + Color32::WHITE, + ); + + // Artist + if let Some(artist) = &track.artist { + ui.painter().text( + text_rect.left_top() + Vec2::new(0.0, 16.0), + egui::Align2::LEFT_TOP, + artist, + egui::FontId::proportional(11.0), + Color32::GRAY, + ); + } + + // BPM + let bpm_text = track + .bpm + .map(|b| format!("{:.0}", b)) + .unwrap_or_else(|| "---".to_string()); + let bpm_color = if track.bpm.is_some() { + Color32::from_rgb(0, 200, 100) + } else { + Color32::DARK_GRAY + }; + ui.painter().text( + egui::pos2(text_rect.right() - 60.0, text_rect.center().y), + egui::Align2::LEFT_CENTER, + &bpm_text, + egui::FontId::monospace(12.0), + bpm_color, + ); + + // Duration + ui.painter().text( + egui::pos2(text_rect.right(), text_rect.center().y), + egui::Align2::RIGHT_CENTER, + format_duration(track.duration_seconds), + egui::FontId::monospace(12.0), + Color32::GRAY, + ); + } + + // Handle drag - set payload when dragging + if base_response.drag_started() { + base_response.dnd_set_drag_payload(payload); + } + + // Show drag preview while dragging + if base_response.dragged() { + // Paint a preview at cursor + if let Some(pointer_pos) = ui.ctx().pointer_hover_pos() { + let preview_rect = egui::Rect::from_min_size( + pointer_pos + Vec2::new(10.0, 10.0), + Vec2::new(200.0, 30.0), + ); + ui.painter().rect_filled( + preview_rect, + Rounding::same(4), + Color32::from_rgba_unmultiplied(60, 80, 120, 200), + ); + ui.painter().text( + preview_rect.center(), + egui::Align2::CENTER_CENTER, + &track.title, + egui::FontId::proportional(12.0), + Color32::WHITE, + ); + } + } + + // Select on mouse down (not clicked, which requires no movement) + // This makes selection feel more responsive + if base_response.is_pointer_button_down_on() { + new_selected = Some(idx); + } + + // Handle double-click to load to Deck A + if base_response.double_clicked() { + double_clicked_track_id = Some(track_id); + } + + // Right-click context menu + let track_bpm = track.bpm.unwrap_or(120.0); + let track_file_path = track.file_path.clone(); + base_response.context_menu(|ui| { + if ui.button("Reanalyze").clicked() { + context_reanalyze_track_id = Some(track_id); + ui.close_menu(); + } + if ui.button("Edit BPM...").clicked() { + context_edit_bpm_track = Some((track_id, track_bpm)); + ui.close_menu(); + } + if ui.button("Delete from library").clicked() { + context_delete_track_id = Some(track_id); + ui.close_menu(); + } + ui.separator(); + #[cfg(target_os = "macos")] + if ui.button("Show in Finder").clicked() { + context_show_in_finder_path = Some(track_file_path.clone()); + ui.close_menu(); + } + #[cfg(target_os = "linux")] + if ui.button("Show in File Manager").clicked() { + context_show_in_finder_path = Some(track_file_path.clone()); + ui.close_menu(); + } + #[cfg(target_os = "windows")] + if ui.button("Show in Explorer").clicked() { + context_show_in_finder_path = Some(track_file_path.clone()); + ui.close_menu(); + } + }); + + ui.add_space(2.0); + } + + // Update selection after loop + self.selected_index = new_selected; + } + }); + + // Handle double-click load (send command after ScrollArea to avoid borrow issues) + if let Some(track_id) = double_clicked_track_id { + let _ = console_tx.send(ConsoleCommand::DjLoadTrack { deck: 0, track_id }); + } + + // Handle context menu actions + if let Some(track_id) = context_reanalyze_track_id { + let _ = console_tx.send(ConsoleCommand::DjReanalyzeTrack { track_id }); + } + if let Some((track_id, bpm)) = context_edit_bpm_track { + self.editing_bpm_track_id = Some(track_id); + self.bpm_edit_value = format!("{:.1}", bpm); + } + if let Some(track_id) = context_delete_track_id { + let _ = console_tx.send(ConsoleCommand::DjDeleteTrack { track_id }); + } + if let Some(path) = context_show_in_finder_path { + open_in_file_browser(&path); + } + + ui.add_space(8.0); + + // Bottom controls + ui.horizontal(|ui| { + let selected_track_id = self.selected_track().map(|t| t.id); + + if ui + .add_sized( + Vec2::new(80.0, 24.0), + egui::Button::new(RichText::new("Load A").size(11.0)), + ) + .clicked() + { + eprintln!( + "DEBUG: Load A button clicked, selected_track_id={:?}", + selected_track_id + ); + if let Some(track_id) = selected_track_id { + log::info!( + "UI: Load A clicked - sending DjLoadTrack deck=0, track_id={}", + track_id + ); + eprintln!( + "DEBUG: Sending DjLoadTrack command for track_id={}", + track_id + ); + let _ = console_tx.send(ConsoleCommand::DjLoadTrack { deck: 0, track_id }); + } else { + log::warn!("UI: Load A clicked but no track selected"); + eprintln!("DEBUG: No track selected!"); + } + } + + if ui + .add_sized( + Vec2::new(80.0, 24.0), + egui::Button::new(RichText::new("Load B").size(11.0)), + ) + .clicked() + { + if let Some(track_id) = selected_track_id { + log::info!( + "UI: Load B clicked - sending DjLoadTrack deck=1, track_id={}", + track_id + ); + let _ = console_tx.send(ConsoleCommand::DjLoadTrack { deck: 1, track_id }); + } else { + log::warn!("UI: Load B clicked but no track selected"); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format!("{} tracks", self.tracks.len())) + .size(11.0) + .color(Color32::GRAY), + ); + }); + }); + + // BPM edit dialog - allows manual BPM correction and triggers reanalysis + if let Some(track_id) = self.editing_bpm_track_id { + let mut open = true; + egui::Window::new("Edit BPM") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut open) + .show(ui.ctx(), |ui| { + ui.horizontal(|ui| { + ui.label("BPM:"); + ui.add(egui::TextEdit::singleline(&mut self.bpm_edit_value).desired_width(60.0)); + }); + ui.add_space(4.0); + ui.label( + RichText::new("Saving will update the BPM and regenerate\nthe beat grid based on the new tempo.") + .size(11.0) + .color(Color32::GRAY), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + if let Ok(bpm) = self.bpm_edit_value.parse::() { + // Update BPM and trigger reanalysis to regenerate beat grid + let _ = console_tx.send(ConsoleCommand::DjUpdateTrackBpm { track_id, bpm }); + let _ = console_tx.send(ConsoleCommand::DjReanalyzeTrack { track_id }); + } + self.editing_bpm_track_id = None; + } + if ui.button("Cancel").clicked() { + self.editing_bpm_track_id = None; + } + }); + }); + if !open { + self.editing_bpm_track_id = None; + } + } + } + + /// Toggle sort on a column. + fn toggle_sort(&mut self, column: SortColumn) { + if self.sort_column == column { + self.sort_ascending = !self.sort_ascending; + } else { + self.sort_column = column; + self.sort_ascending = true; + } + self.sort_tracks(); + } + + /// Sort tracks by current column. + fn sort_tracks(&mut self) { + match self.sort_column { + SortColumn::Title => { + self.tracks.sort_by(|a, b| { + let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase()); + if self.sort_ascending { + cmp + } else { + cmp.reverse() + } + }); + } + SortColumn::Artist => { + self.tracks.sort_by(|a, b| { + let a_artist = a.artist.as_deref().unwrap_or(""); + let b_artist = b.artist.as_deref().unwrap_or(""); + let cmp = a_artist.to_lowercase().cmp(&b_artist.to_lowercase()); + if self.sort_ascending { + cmp + } else { + cmp.reverse() + } + }); + } + SortColumn::Bpm => { + self.tracks.sort_by(|a, b| { + let a_bpm = a.bpm.unwrap_or(0.0); + let b_bpm = b.bpm.unwrap_or(0.0); + let cmp = a_bpm + .partial_cmp(&b_bpm) + .unwrap_or(std::cmp::Ordering::Equal); + if self.sort_ascending { + cmp + } else { + cmp.reverse() + } + }); + } + SortColumn::Duration => { + self.tracks.sort_by(|a, b| { + let cmp = a + .duration_seconds + .partial_cmp(&b.duration_seconds) + .unwrap_or(std::cmp::Ordering::Equal); + if self.sort_ascending { + cmp + } else { + cmp.reverse() + } + }); + } + } + } + + /// Filter tracks based on search query. + fn filter_tracks(&mut self) { + // In a real implementation, this would query the database + // For now, tracks are pre-loaded and we just update selected_index + self.selected_index = None; + } + + /// Get tracks filtered by search query. + fn get_filtered_tracks(&self) -> Vec<&TrackEntry> { + if self.search_query.is_empty() { + self.tracks.iter().collect() + } else { + let query = self.search_query.to_lowercase(); + self.tracks + .iter() + .filter(|t| { + t.title.to_lowercase().contains(&query) + || t.artist + .as_ref() + .map(|a| a.to_lowercase().contains(&query)) + .unwrap_or(false) + }) + .collect() + } + } + + /// Get the currently selected track. + pub fn selected_track(&self) -> Option<&TrackEntry> { + self.selected_index.and_then(|idx| { + let filtered = self.get_filtered_tracks(); + filtered.get(idx).copied() + }) + } + + /// Set the track list (called when library is updated). + pub fn set_tracks(&mut self, tracks: Vec) { + self.tracks = tracks; + self.sort_tracks(); + self.selected_index = None; + } + + /// Update BPM values for tracks that have been analyzed. + /// This is called each frame to sync BPM values without replacing the entire list. + pub fn update_track_bpms(&mut self, source_tracks: &[halo_core::DjTrackInfo]) { + for source in source_tracks { + if let Some(track) = self.tracks.iter_mut().find(|t| t.id == source.id) { + if track.bpm != source.bpm { + track.bpm = source.bpm; + } + } + } + } +} + +/// Format duration as MM:SS. +fn format_duration(seconds: f64) -> String { + let mins = (seconds / 60.0).floor() as u32; + let secs = (seconds % 60.0).floor() as u32; + format!("{}:{:02}", mins, secs) +} + +/// Open the file's location in the system file browser. +fn open_in_file_browser(path: &str) { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open") + .args(["-R", path]) + .spawn(); + } + #[cfg(target_os = "linux")] + { + // Open parent directory + if let Some(parent) = std::path::Path::new(path).parent() { + let _ = std::process::Command::new("xdg-open").arg(parent).spawn(); + } + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("explorer") + .args(["/select,", path]) + .spawn(); + } +} diff --git a/crates/ui/src/dj/mod.rs b/crates/ui/src/dj/mod.rs new file mode 100644 index 0000000..26e3900 --- /dev/null +++ b/crates/ui/src/dj/mod.rs @@ -0,0 +1,188 @@ +//! DJ panel UI components. +//! +//! Provides a dual-deck DJ interface with: +//! - Deck displays (waveform, transport, BPM) +//! - Track browser +//! - Hot cue buttons +//! - Pitch/tempo controls + +mod deck; +mod library; +mod waveform_texture; + +use std::sync::Arc; + +pub use deck::DeckWidget; +use eframe::egui; +use halo_core::ConsoleCommand; +pub use library::{LibraryBrowser, TrackDragPayload}; +use tokio::sync::mpsc; + +use crate::state::ConsoleState; + +/// State for the DJ panel. +pub struct DjPanel { + /// Deck A widget state. + deck_a: DeckWidget, + /// Deck B widget state. + deck_b: DeckWidget, + /// Library browser state. + library: LibraryBrowser, + /// Whether the library panel is expanded. + library_expanded: bool, + /// Whether we've requested the library. + library_requested: bool, + /// Last known track count to detect changes. + last_track_count: usize, +} + +impl Default for DjPanel { + fn default() -> Self { + Self { + deck_a: DeckWidget::default(), + deck_b: DeckWidget::default(), + library: LibraryBrowser::default(), + library_expanded: false, + library_requested: false, + last_track_count: 0, + } + } +} + +impl DjPanel { + /// Render the DJ panel. + pub fn render( + &mut self, + ctx: &egui::Context, + state: &ConsoleState, + console_tx: &mpsc::UnboundedSender, + ) { + // Request library on first render or after import + let track_count_changed = state.dj_tracks.len() != self.last_track_count; + if !self.library_requested || track_count_changed { + let _ = console_tx.send(ConsoleCommand::DjQueryLibrary); + self.library_requested = true; + } + + // Update library browser with tracks from state when tracks change + if track_count_changed && !state.dj_tracks.is_empty() { + let tracks: Vec = state + .dj_tracks + .iter() + .map(|t| library::TrackEntry { + id: t.id, + title: t.title.clone(), + artist: t.artist.clone(), + duration_seconds: t.duration_seconds, + bpm: t.bpm, + file_path: t.file_path.clone(), + }) + .collect(); + self.library.set_tracks(tracks); + self.last_track_count = state.dj_tracks.len(); + } else { + // Sync BPM values for tracks that have been analyzed + // (when track count is the same but BPM values may have changed) + self.library.update_track_bpms(&state.dj_tracks); + } + + // Sync deck state from console state + self.deck_a.track_title = state.dj_deck_a.track_title.clone(); + self.deck_a.track_artist = state.dj_deck_a.track_artist.clone(); + self.deck_a.duration_seconds = state.dj_deck_a.duration_seconds; + self.deck_a.position_seconds = state.dj_deck_a.position_seconds; + self.deck_a.adjusted_bpm = state.dj_deck_a.bpm.unwrap_or(120.0); + self.deck_a.is_playing = state.dj_deck_a.is_playing; + self.deck_a.waiting_for_quantized_start = state.dj_deck_a.waiting_for_quantized_start; + self.deck_a.cue_point = state.dj_deck_a.cue_point; + // Use Arc::ptr_eq for fast identity comparison (zero-copy waveform sharing) + if !Arc::ptr_eq(&self.deck_a.waveform, &state.dj_deck_a.waveform) { + self.deck_a.waveform = Arc::clone(&state.dj_deck_a.waveform); + self.deck_a.waveform_colors = state.dj_deck_a.waveform_colors.as_ref().map(Arc::clone); + } + // Always sync beat positions (nudge changes values without changing count) + if self.deck_a.beat_positions != state.dj_deck_a.beat_positions { + self.deck_a.beat_positions = state.dj_deck_a.beat_positions.clone(); + } + self.deck_a.first_beat_offset = state.dj_deck_a.first_beat_offset; + + self.deck_b.track_title = state.dj_deck_b.track_title.clone(); + self.deck_b.track_artist = state.dj_deck_b.track_artist.clone(); + self.deck_b.duration_seconds = state.dj_deck_b.duration_seconds; + self.deck_b.position_seconds = state.dj_deck_b.position_seconds; + self.deck_b.adjusted_bpm = state.dj_deck_b.bpm.unwrap_or(120.0); + self.deck_b.is_playing = state.dj_deck_b.is_playing; + self.deck_b.waiting_for_quantized_start = state.dj_deck_b.waiting_for_quantized_start; + self.deck_b.cue_point = state.dj_deck_b.cue_point; + // Use Arc::ptr_eq for fast identity comparison (zero-copy waveform sharing) + if !Arc::ptr_eq(&self.deck_b.waveform, &state.dj_deck_b.waveform) { + self.deck_b.waveform = Arc::clone(&state.dj_deck_b.waveform); + self.deck_b.waveform_colors = state.dj_deck_b.waveform_colors.as_ref().map(Arc::clone); + } + // Always sync beat positions (nudge changes values without changing count) + if self.deck_b.beat_positions != state.dj_deck_b.beat_positions { + self.deck_b.beat_positions = state.dj_deck_b.beat_positions.clone(); + } + self.deck_b.first_beat_offset = state.dj_deck_b.first_beat_offset; + + // Sync Master Tempo state + self.deck_a.master_tempo_enabled = state.dj_deck_a.master_tempo_enabled; + self.deck_a.tempo_range = state.dj_deck_a.tempo_range; + self.deck_b.master_tempo_enabled = state.dj_deck_b.master_tempo_enabled; + self.deck_b.tempo_range = state.dj_deck_b.tempo_range; + + // Sync pitch fader position from backend (for sync following) + self.deck_a.pitch = state.dj_deck_a.pitch_percent; + self.deck_b.pitch = state.dj_deck_b.pitch_percent; + + // Sync Loop state + self.deck_a.loop_in = state.dj_deck_a.loop_in; + self.deck_a.loop_out = state.dj_deck_a.loop_out; + self.deck_a.loop_active = state.dj_deck_a.loop_active; + self.deck_a.loop_beat_count = state.dj_deck_a.loop_beat_count; + self.deck_b.loop_in = state.dj_deck_b.loop_in; + self.deck_b.loop_out = state.dj_deck_b.loop_out; + self.deck_b.loop_active = state.dj_deck_b.loop_active; + self.deck_b.loop_beat_count = state.dj_deck_b.loop_beat_count; + + // Left side panel for library browser + egui::SidePanel::left("dj_library_panel") + .resizable(true) + .default_width(300.0) + .min_width(200.0) + .show(ctx, |ui| { + ui.heading("Library"); + ui.separator(); + self.library.render(ui, console_tx); + }); + + // Main content area with two decks + egui::CentralPanel::default().show(ctx, |ui| { + // Top area: Both decks side by side + let available_width = ui.available_width(); + let deck_width = (available_width - 20.0) / 2.0; + + ui.horizontal(|ui| { + // Deck A + ui.vertical(|ui| { + ui.set_width(deck_width); + self.deck_a.render(ui, "A", 0, console_tx); + }); + + ui.add_space(20.0); + + // Deck B + ui.vertical(|ui| { + ui.set_width(deck_width); + self.deck_b.render(ui, "B", 1, console_tx); + }); + }); + }); + + // Request continuous repaints while cue button is held on either deck + // This ensures hold detection works properly in egui's repaint model + if self.deck_a.is_cue_held() || self.deck_b.is_cue_held() { + ctx.request_repaint(); + } + } +} diff --git a/crates/ui/src/dj/waveform_texture.rs b/crates/ui/src/dj/waveform_texture.rs new file mode 100644 index 0000000..9dc2844 --- /dev/null +++ b/crates/ui/src/dj/waveform_texture.rs @@ -0,0 +1,281 @@ +//! GPU-accelerated waveform texture rendering. +//! +//! Pre-renders waveform data to a GPU texture for efficient scrolling display. +//! Instead of drawing thousands of line segments per frame, we render once to +//! a texture and then just scroll/clip the texture (GPU handles this efficiently). + +use std::sync::Arc; + +use eframe::egui::{self, Color32, ColorImage, TextureHandle, TextureOptions}; + +/// Height of the rendered waveform texture in pixels. +/// Using a taller texture for better vertical resolution when zoomed. +const TEXTURE_HEIGHT: usize = 256; + +/// Maximum texture width - balances quality vs memory. +/// 8000 pixels allows ~26 pixels per second for a 5-minute track. +const MAX_TEXTURE_WIDTH: usize = 8000; + +/// Cached GPU texture for waveform display. +pub struct WaveformTexture { + /// The GPU texture handle. + texture: Option, + /// Pointer to the waveform data this texture was rendered from. + /// Used to detect when we need to re-render. + waveform_ptr: usize, + /// Pointer to the color data this texture was rendered from. + colors_ptr: usize, + /// Number of samples in the waveform when texture was created. + sample_count: usize, + /// Width of the texture in pixels. + texture_width: usize, +} + +impl Default for WaveformTexture { + fn default() -> Self { + Self { + texture: None, + waveform_ptr: 0, + colors_ptr: 0, + sample_count: 0, + texture_width: 0, + } + } +} + +impl WaveformTexture { + /// Check if the texture needs to be regenerated based on waveform data changes. + pub fn needs_update( + &self, + waveform: &Arc>, + colors: &Option>>, + ) -> bool { + let waveform_ptr = Arc::as_ptr(waveform) as usize; + let colors_ptr = colors + .as_ref() + .map(|c| Arc::as_ptr(c) as usize) + .unwrap_or(0); + + self.texture.is_none() + || self.waveform_ptr != waveform_ptr + || self.colors_ptr != colors_ptr + || self.sample_count != waveform.len() + } + + /// Render waveform data to a GPU texture. + /// + /// This is called once when the waveform changes, not every frame. + pub fn update( + &mut self, + ctx: &egui::Context, + waveform: &Arc>, + colors: &Option>>, + texture_width: usize, + ) { + if waveform.is_empty() || texture_width == 0 { + self.texture = None; + self.sample_count = 0; + return; + } + + // Track what data we rendered from + self.waveform_ptr = Arc::as_ptr(waveform) as usize; + self.colors_ptr = colors + .as_ref() + .map(|c| Arc::as_ptr(c) as usize) + .unwrap_or(0); + self.sample_count = waveform.len(); + self.texture_width = texture_width; + + // Create the image data + let mut pixels = vec![Color32::TRANSPARENT; texture_width * TEXTURE_HEIGHT]; + let mid_y = TEXTURE_HEIGHT / 2; + let num_samples = waveform.len(); + let samples_per_pixel = num_samples as f32 / texture_width as f32; + + // First pass: calculate heights for all columns to enable smooth connections + let mut heights: Vec = Vec::with_capacity(texture_width); + let mut sample_colors: Vec = Vec::with_capacity(texture_width); + + for x in 0..texture_width { + let sample_idx = (x as f32 * samples_per_pixel) as usize; + if sample_idx < num_samples { + let amplitude = waveform[sample_idx].abs(); + let height = (amplitude * (TEXTURE_HEIGHT / 2) as f32 * 0.95) as usize; + heights.push(height); + + // Get color for this sample + let color = if let Some(ref color_data) = colors { + if sample_idx < color_data.len() { + let (low, mid, high) = color_data[sample_idx]; + frequency_bands_to_color(low, mid, high) + } else { + gradient_color(sample_idx as f64 / num_samples as f64) + } + } else { + gradient_color(sample_idx as f64 / num_samples as f64) + }; + sample_colors.push(color); + } else { + heights.push(0); + sample_colors.push(Color32::TRANSPARENT); + } + } + + // Second pass: draw outline-style waveform (connecting adjacent peaks) + for x in 0..texture_width { + let height = heights[x]; + let color = sample_colors[x]; + + if height == 0 { + continue; + } + + // Get adjacent heights for smooth connections + let prev_height = if x > 0 { heights[x - 1] } else { height }; + let next_height = if x + 1 < texture_width { + heights[x + 1] + } else { + height + }; + + // Calculate the range to fill for smooth diagonal connections + let min_height = height.min(prev_height).min(next_height); + let max_height = height.max(prev_height).max(next_height); + + // Draw the outline edge with smooth connections to neighbors + // Fill from min to max height to create connected envelope + for dy in min_height..=max_height { + // Top edge (above center) + if mid_y + dy < TEXTURE_HEIGHT { + pixels[(mid_y + dy) * texture_width + x] = color; + } + // Bottom edge (below center) + if mid_y >= dy { + pixels[(mid_y - dy) * texture_width + x] = color; + } + } + + // Add a subtle fill inside the envelope (dimmed version of color) + let fill_color = Color32::from_rgba_unmultiplied( + color.r() / 3, + color.g() / 3, + color.b() / 3, + 180, + ); + for dy in 1..min_height { + if mid_y + dy < TEXTURE_HEIGHT { + pixels[(mid_y + dy) * texture_width + x] = fill_color; + } + if mid_y >= dy { + pixels[(mid_y - dy) * texture_width + x] = fill_color; + } + } + } + + // Create the texture + let size = [texture_width, TEXTURE_HEIGHT]; + let image = ColorImage { + size, + pixels, + source_size: egui::Vec2::new(texture_width as f32, TEXTURE_HEIGHT as f32), + }; + + self.texture = Some(ctx.load_texture( + "waveform", + image, + TextureOptions { + // Use Nearest filtering for crisp, sharp waveform pixels + magnification: egui::TextureFilter::Nearest, + minification: egui::TextureFilter::Nearest, + ..Default::default() + }, + )); + } + + /// Draw the overview waveform (full track visible). + /// + /// The playhead position is indicated separately; this just draws the waveform. + pub fn draw_overview(&self, ui: &mut egui::Ui, rect: egui::Rect) { + if let Some(ref texture) = self.texture { + // Draw the full texture scaled to fit the rect + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(texture.id(), rect, uv, Color32::WHITE); + } + } + + /// Draw the zoomed waveform (scrolling CDJ-style view). + /// + /// Shows a portion of the waveform centered around the playhead position. + pub fn draw_zoomed( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + position_seconds: f64, + duration_seconds: f64, + visible_duration: f64, + playhead_ratio: f32, + ) { + if let Some(ref texture) = self.texture { + if duration_seconds <= 0.0 { + return; + } + + // Calculate the visible time window + let window_start = position_seconds - (visible_duration * playhead_ratio as f64); + let window_end = window_start + visible_duration; + + // Convert to UV coordinates (0.0 to 1.0) + let uv_start = (window_start / duration_seconds).clamp(0.0, 1.0) as f32; + let uv_end = (window_end / duration_seconds).clamp(0.0, 1.0) as f32; + + // Handle edge cases where window extends beyond track + let uv = egui::Rect::from_min_max(egui::pos2(uv_start, 0.0), egui::pos2(uv_end, 1.0)); + + // Calculate the visible portion of the rect when window extends beyond track + let visible_start_ratio = if window_start < 0.0 { + (-window_start / visible_duration) as f32 + } else { + 0.0 + }; + let visible_end_ratio = if window_end > duration_seconds { + 1.0 - ((window_end - duration_seconds) / visible_duration) as f32 + } else { + 1.0 + }; + + let draw_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + rect.width() * visible_start_ratio, rect.top()), + egui::pos2( + rect.left() + rect.width() * visible_end_ratio, + rect.bottom(), + ), + ); + + ui.painter() + .image(texture.id(), draw_rect, uv, Color32::WHITE); + } + } + + /// Returns true if a texture is loaded. + pub fn has_texture(&self) -> bool { + self.texture.is_some() + } +} + +/// Convert 3-band frequency data to RGB color (CDJ/rekordbox style). +fn frequency_bands_to_color(low: f32, mid: f32, high: f32) -> Color32 { + let scale = 2.5; + let r = (low * scale * 255.0).clamp(0.0, 255.0) as u8; + let g = (mid * scale * 255.0).clamp(0.0, 255.0) as u8; + let b = (high * scale * 255.0).clamp(0.0, 255.0) as u8; + Color32::from_rgb(r, g, b) +} + +/// Get gradient color for waveform based on position (fallback). +fn gradient_color(progress: f64) -> Color32 { + let r = (100.0 + progress * 155.0) as u8; + let g = (200.0 - progress * 100.0) as u8; + let b = 255; + Color32::from_rgb(r, g, b) +} diff --git a/crates/ui/src/footer.rs b/crates/ui/src/footer.rs index 8eb8c36..18bc76a 100644 --- a/crates/ui/src/footer.rs +++ b/crates/ui/src/footer.rs @@ -1,4 +1,4 @@ -use eframe::egui::{Align, CornerRadius, Direction, Layout, RichText}; +use eframe::egui::{Align, Color32, CornerRadius, Direction, Layout, RichText}; use halo_core::ConsoleCommand; use tokio::sync::mpsc; @@ -8,7 +8,6 @@ pub fn render( ui: &mut eframe::egui::Ui, _console_tx: &mpsc::UnboundedSender, state: &crate::state::ConsoleState, - fps: u32, ) { let theme = Theme::default(); let fixture_count = state.fixtures.len(); @@ -24,11 +23,29 @@ pub fn render( ui.horizontal(|ui| { ui.add_space(12.0); - ui.label( - RichText::new(format!("FPS: {}", fps)) - .size(12.0) - .color(theme.text_dim), - ); + // Show status message if available, otherwise empty + if let Some(ref message) = state.status_message { + let status_text = if let Some((current, total, intra_progress)) = state.status_progress + { + let percentage = if total > 0 { + // Calculate overall progress including intra-track progress + // For track 2/10 at 50% done: (1 + 0.5) / 10 = 15% + let completed = (current.saturating_sub(1)) as f32; + let overall = (completed + intra_progress) / total as f32; + (overall * 100.0) as u32 + } else { + 0 + }; + format!("{} ({}/{} - {}%)", message, current, total, percentage) + } else { + message.clone() + }; + ui.label( + RichText::new(status_text) + .size(12.0) + .color(Color32::from_rgb(100, 180, 255)), // Light blue for status + ); + } ui.with_layout( Layout::centered_and_justified(Direction::LeftToRight), @@ -50,7 +67,7 @@ pub fn render( ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.add_space(12.0); - ui.label(RichText::new("Halo v0.4").size(12.0).color(theme.text_dim)); + ui.label(RichText::new("Halo v0.5").size(12.0).color(theme.text_dim)); }); }); } diff --git a/crates/ui/src/header.rs b/crates/ui/src/header.rs index 704cdaa..a705eee 100644 --- a/crates/ui/src/header.rs +++ b/crates/ui/src/header.rs @@ -70,6 +70,18 @@ pub fn render( ui.separator(); + if ui.button("Import Music Folder...").clicked() { + if let Some(path) = rfd::FileDialog::new() + .set_title("Import Music Folder") + .pick_folder() + { + let _ = console_tx.send(ConsoleCommand::DjImportFolder { path }); + } + ui.close(); + } + + ui.separator(); + if ui.button("Settings").clicked() { settings_panel.open(); ui.close(); @@ -111,6 +123,12 @@ pub fn render( }); // Tab selector ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .selectable_label(matches!(active_tab, ActiveTab::Dj), "DJ") + .clicked() + { + *active_tab = ActiveTab::Dj; + } if ui .selectable_label(matches!(active_tab, ActiveTab::ShowManager), "Shows") .clicked() diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 81821d8..cc3d89d 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -14,6 +14,7 @@ mod utils; // Enable all UI modules mod cue; mod cue_editor; +mod dj; mod fader; mod fixture; mod master; @@ -30,6 +31,7 @@ pub enum ActiveTab { CueEditor, PatchPanel, ShowManager, + Dj, } pub struct HaloApp { @@ -61,6 +63,7 @@ pub struct HaloApp { cue_panel_state: cue::CuePanel, settings_panel: settings::SettingsPanel, timeline_state: timeline::TimelineState, + dj_panel_state: dj::DjPanel, } impl HaloApp { @@ -101,6 +104,7 @@ impl HaloApp { cue_panel_state: cue::CuePanel::default(), settings_panel: settings::SettingsPanel::new(), timeline_state: timeline::TimelineState::default(), + dj_panel_state: dj::DjPanel::default(), } } @@ -139,6 +143,59 @@ impl HaloApp { } } + /// Handle global keyboard shortcuts for transport control. + /// + /// Shortcuts: + /// - Space: Play/Pause toggle for current cue list + /// - Escape: Stop playback + /// - Right Arrow: Next cue + /// - Left Arrow: Previous cue + /// - T: Tap tempo + fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) { + // Only handle shortcuts when no text input is focused + if ctx.memory(|mem| mem.focused().is_some()) { + return; + } + + ctx.input(|input| { + // Space: Play/Pause toggle + if input.key_pressed(egui::Key::Space) { + match self.state.playback_state { + halo_core::PlaybackState::Playing => { + let _ = self.console_tx.send(ConsoleCommand::Pause); + } + halo_core::PlaybackState::Stopped | halo_core::PlaybackState::Holding => { + let _ = self.console_tx.send(ConsoleCommand::Play); + } + } + } + + // Escape: Stop playback + if input.key_pressed(egui::Key::Escape) { + let _ = self.console_tx.send(ConsoleCommand::Stop); + } + + // Right Arrow: Next cue + if input.key_pressed(egui::Key::ArrowRight) { + let _ = self.console_tx.send(ConsoleCommand::NextCue { + list_index: self.state.current_cue_list_index, + }); + } + + // Left Arrow: Previous cue + if input.key_pressed(egui::Key::ArrowLeft) { + let _ = self.console_tx.send(ConsoleCommand::PrevCue { + list_index: self.state.current_cue_list_index, + }); + } + + // T: Tap tempo + if input.key_pressed(egui::Key::T) { + let _ = self.console_tx.send(ConsoleCommand::TapTempo); + } + }); + } + fn render_ui(&mut self, ctx: &egui::Context) { // Header egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { @@ -174,7 +231,7 @@ impl HaloApp { ui.separator(); // Show footer status - footer::render(ui, &self.console_tx, &self.state, self.fps); + footer::render(ui, &self.console_tx, &self.state); }); match self.active_tab { @@ -227,6 +284,10 @@ impl HaloApp { self.show_panel_state .render(ctx, &self.state, &self.console_tx); } + ActiveTab::Dj => { + self.dj_panel_state + .render(ctx, &self.state, &self.console_tx); + } } // Render settings panel (modal window) @@ -252,6 +313,9 @@ impl eframe::App for HaloApp { self.initial_show_loaded = true; } + // Handle keyboard shortcuts for transport control + self.handle_keyboard_shortcuts(ctx); + // Process all updates first self.process_engine_updates(); diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index edf3b82..4bc1216 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -49,6 +49,9 @@ pub struct SettingsPanel { // Fixture settings pub enable_pan_tilt_limits: bool, + // Push 2 settings + pub push2_enabled: bool, + // Internal state initialized: bool, } @@ -90,6 +93,9 @@ impl Default for SettingsPanel { // Fixture defaults enable_pan_tilt_limits: true, + // Push 2 defaults + push2_enabled: false, + // Internal state initialized: false, } @@ -144,6 +150,9 @@ impl SettingsPanel { // Load fixture settings self.enable_pan_tilt_limits = settings.enable_pan_tilt_limits; + + // Load Push 2 settings + self.push2_enabled = settings.push2_enabled; } pub fn render( @@ -618,6 +627,8 @@ impl SettingsPanel { pixel_universe_mapping: std::collections::HashMap::new(), enable_pan_tilt_limits: self.enable_pan_tilt_limits, + + push2_enabled: self.push2_enabled, }; // Send update command diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index 77d4a2b..0100dfe 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -1,13 +1,69 @@ use std::collections::HashMap; +use std::sync::Arc; use std::time::SystemTime; use halo_core::audio::waveform::WaveformData; use halo_core::{ - AudioDeviceInfo, ConsoleCommand, CueList, PlaybackState, RhythmState, Settings, Show, TimeCode, + AudioDeviceInfo, ConsoleCommand, CueList, DjTrackInfo, PlaybackState, RhythmState, Settings, + Show, TimeCode, }; use halo_fixtures::{Fixture, FixtureLibrary}; use tokio::sync::mpsc; +/// State for a DJ deck. +#[derive(Debug, Clone)] +pub struct DjDeckState { + pub track_title: Option, + pub track_artist: Option, + pub duration_seconds: f64, + pub position_seconds: f64, + pub bpm: Option, + pub is_playing: bool, + pub waiting_for_quantized_start: bool, + pub cue_point: Option, + /// Waveform samples (Arc for zero-copy sharing between state and UI). + pub waveform: Arc>, + /// 3-band frequency data for colored waveform (low, mid, high). + /// Arc for zero-copy sharing. None for legacy tracks without frequency analysis. + pub waveform_colors: Option>>, + pub beat_positions: Vec, + pub first_beat_offset: f64, + pub master_tempo_enabled: bool, + pub tempo_range: u8, // 0=±6%, 1=±10%, 2=±16%, 3=Wide (±100%) + pub pitch_percent: f64, // Pitch fader position (-1.0 to 1.0) + // Loop state + pub loop_in: Option, + pub loop_out: Option, + pub loop_active: bool, + pub loop_beat_count: f64, +} + +impl Default for DjDeckState { + fn default() -> Self { + Self { + track_title: None, + track_artist: None, + duration_seconds: 0.0, + position_seconds: 0.0, + bpm: None, + is_playing: false, + waiting_for_quantized_start: false, + cue_point: None, + waveform: Arc::new(Vec::new()), + waveform_colors: None, + beat_positions: Vec::new(), + first_beat_offset: 0.0, + master_tempo_enabled: false, + tempo_range: 1, + pitch_percent: 0.0, + loop_in: None, + loop_out: None, + loop_active: false, + loop_beat_count: 4.0, + } + } +} + #[derive(Debug, Clone)] pub struct ConsoleState { pub fixtures: HashMap, @@ -39,6 +95,11 @@ pub struct ConsoleState { pub audio_duration: Option, pub audio_bpm: Option, pub pixel_data: HashMap>, + pub dj_tracks: Vec, + pub dj_deck_a: DjDeckState, + pub dj_deck_b: DjDeckState, + pub status_message: Option, + pub status_progress: Option<(usize, usize, f32)>, // (current, total, intra_track_progress) } impl Default for ConsoleState { @@ -65,6 +126,8 @@ impl Default for ConsoleState { bars_per_phrase: 4, last_tap_time: None, tap_count: 0, + bpm: 120.0, + tempo_source: halo_core::TempoSource::Internal, }, show: None, timecode: None, @@ -81,6 +144,11 @@ impl Default for ConsoleState { audio_duration: None, audio_bpm: None, pixel_data: HashMap::new(), + dj_tracks: Vec::new(), + dj_deck_a: DjDeckState::default(), + dj_deck_b: DjDeckState::default(), + status_message: None, + status_progress: None, } } } @@ -243,6 +311,206 @@ impl ConsoleState { self.pixel_data.insert(fixture_id, pixels); } } + halo_core::ConsoleEvent::DjLibraryTracks { tracks } => { + self.dj_tracks = tracks; + } + halo_core::ConsoleEvent::DjTrackLoaded { + deck, + track_id: _, + title, + artist, + duration_seconds, + bpm, + } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.track_title = Some(title); + deck_state.track_artist = artist; + deck_state.duration_seconds = duration_seconds; + deck_state.bpm = bpm; + deck_state.position_seconds = 0.0; + deck_state.is_playing = false; // Reset play state when new track is loaded + deck_state.waiting_for_quantized_start = false; + deck_state.cue_point = None; // Clear cue point for new track + deck_state.waveform = Arc::new(Vec::new()); // Clear previous waveform immediately + deck_state.waveform_colors = None; // Clear previous color data + deck_state.loop_in = None; // Clear loop state + deck_state.loop_out = None; + deck_state.loop_active = false; + } + halo_core::ConsoleEvent::DjDeckStateChanged { + deck, + is_playing, + position_seconds, + bpm, + } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.is_playing = is_playing; + deck_state.position_seconds = position_seconds; + // Detect quantized sync wait state: position is negative (virtual countdown) + deck_state.waiting_for_quantized_start = position_seconds < 0.0; + if let Some(new_bpm) = bpm { + deck_state.bpm = Some(new_bpm); + } + } + halo_core::ConsoleEvent::DjCuePointSet { + deck, + position_seconds, + } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.cue_point = Some(position_seconds); + } + halo_core::ConsoleEvent::DjWaveformProgress { + deck, + samples, + frequency_bands, + progress: _, + } => { + // Progressive waveform update - replace with partial samples + // Only update actual decks (0 or 1), ignore library analysis (255) + if deck == 0 { + self.dj_deck_a.waveform = samples; + self.dj_deck_a.waveform_colors = frequency_bands; + } else if deck == 1 { + self.dj_deck_b.waveform = samples; + self.dj_deck_b.waveform_colors = frequency_bands; + } + // deck == 255 is library analysis, ignore for deck display + } + halo_core::ConsoleEvent::DjWaveformLoaded { + deck, + samples, + frequency_bands, + duration_seconds: _, + } => { + // Final waveform - replace with complete samples + // Only update actual decks (0 or 1), ignore library analysis (255) + if deck == 0 { + self.dj_deck_a.waveform = samples; + self.dj_deck_a.waveform_colors = frequency_bands; + } else if deck == 1 { + self.dj_deck_b.waveform = samples; + self.dj_deck_b.waveform_colors = frequency_bands; + } + // deck == 255 is library analysis, ignore for deck display + } + halo_core::ConsoleEvent::DjBeatGridLoaded { + deck, + beat_positions, + first_beat_offset, + bpm: _, + is_nudge, + } => { + // Only update actual decks (0 or 1) + if deck == 0 { + self.dj_deck_a.beat_positions = beat_positions; + self.dj_deck_a.first_beat_offset = first_beat_offset; + // Only sync position on initial load, not on nudge adjustments + if !is_nudge { + self.dj_deck_a.position_seconds = first_beat_offset; + } + } else if deck == 1 { + self.dj_deck_b.beat_positions = beat_positions; + self.dj_deck_b.first_beat_offset = first_beat_offset; + // Only sync position on initial load, not on nudge adjustments + if !is_nudge { + self.dj_deck_b.position_seconds = first_beat_offset; + } + } + } + halo_core::ConsoleEvent::DjMasterTempoChanged { deck, enabled } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.master_tempo_enabled = enabled; + } + halo_core::ConsoleEvent::DjTempoRangeChanged { deck, range } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.tempo_range = range; + } + halo_core::ConsoleEvent::DjLoopStateChanged { + deck, + loop_in, + loop_out, + active, + beat_count, + } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.loop_in = loop_in; + deck_state.loop_out = loop_out; + deck_state.loop_active = active; + deck_state.loop_beat_count = beat_count; + } + halo_core::ConsoleEvent::DjAnalysisProgress { + track_name, + current, + total, + progress, + .. + } => { + self.status_message = Some(format!("Analyzing {}", track_name)); + self.status_progress = Some((current, total, progress)); + } + halo_core::ConsoleEvent::DjAnalysisComplete { track_id, bpm } => { + // Update the track's BPM in our local list + if let Some(track) = self.dj_tracks.iter_mut().find(|t| t.id == track_id) { + track.bpm = bpm; + } + } + halo_core::ConsoleEvent::StatusClear => { + self.status_message = None; + self.status_progress = None; + } + halo_core::ConsoleEvent::DjImportProgress { + current, + total, + current_file, + } => { + // Extract just the filename from the path for display + let filename = std::path::Path::new(¤t_file) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(¤t_file); + self.status_message = Some(format!("Importing {}", filename)); + // Import progress doesn't have intra-track progress, use 0.0 + self.status_progress = Some((current, total, 0.0)); + } + halo_core::ConsoleEvent::DjPitchChanged { + deck, + pitch_percent, + tempo_range, + adjusted_bpm, + } => { + let deck_state = if deck == 0 { + &mut self.dj_deck_a + } else { + &mut self.dj_deck_b + }; + deck_state.pitch_percent = pitch_percent; + deck_state.tempo_range = tempo_range; + deck_state.bpm = Some(adjusted_bpm); + } _ => { // Handle other events as needed } diff --git a/scripts/download_test_audio.sh b/scripts/download_test_audio.sh new file mode 100755 index 0000000..88c1721 --- /dev/null +++ b/scripts/download_test_audio.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# Downloads GiantSteps tempo dataset for BPM accuracy testing +# Audio files are stored in the repo but ignored by .gitignore +# +# Usage: ./scripts/download_test_audio.sh +# +# This script: +# 1. Clones the GiantSteps tempo dataset repository into the fixtures directory +# 2. Downloads the audio files (~1GB) using the dataset's own script +# 3. Generates ground_truth.json from annotations +# 4. Copies audio files to the test fixtures audio directory + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +FIXTURES_DIR="$REPO_ROOT/crates/dj/tests/fixtures" +AUDIO_DIR="$FIXTURES_DIR/audio" +DATASET_DIR="$FIXTURES_DIR/giantsteps-tempo-dataset" + +echo "=== GiantSteps Tempo Dataset Download Script ===" +echo "" +echo "Fixtures directory: $FIXTURES_DIR" +echo "Dataset directory: $DATASET_DIR" +echo "Audio directory: $AUDIO_DIR" +echo "" + +# Check for required tools +command -v curl >/dev/null 2>&1 || { echo "Error: curl is required"; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "Error: python3 is required"; exit 1; } +command -v git >/dev/null 2>&1 || { echo "Error: git is required"; exit 1; } + +# Clone or update the dataset repository +if [ -d "$DATASET_DIR" ]; then + echo "Updating existing GiantSteps repository..." + cd "$DATASET_DIR" + git pull +else + echo "Cloning GiantSteps tempo dataset..." + git clone https://github.com/GiantSteps/giantsteps-tempo-dataset.git "$DATASET_DIR" +fi + +# Create audio directory +mkdir -p "$AUDIO_DIR" + +# Download audio files if not already present +AUDIO_COUNT=$(find "$AUDIO_DIR" -name "*.mp3" 2>/dev/null | wc -l | tr -d ' ') +if [ "$AUDIO_COUNT" -lt 600 ]; then + echo "" + echo "Downloading audio files (this may take a while, ~1GB)..." + echo "" + + cd "$DATASET_DIR" + + # Use the dataset's own download script, but download to our audio directory + # We'll iterate over the md5 files ourselves for better control + + BASEURL="https://www.cp.jku.at/datasets/giantsteps/backup/" + BACKUPURL="http://geo-samples.beatport.com/lofi/" + + TOTAL=$(ls -1 md5/*.md5 2>/dev/null | wc -l | tr -d ' ') + COUNT=0 + ERRORS=0 + + for md5file in md5/*.md5; do + COUNT=$((COUNT + 1)) + + # Get filename from md5 file + basename_md5=$(basename "$md5file") + mp3filename="${basename_md5%.md5}.mp3" + target_file="$AUDIO_DIR/$mp3filename" + + # Skip if already downloaded + if [ -f "$target_file" ]; then + printf "\r[$COUNT/$TOTAL] Skipping $mp3filename (exists) " + continue + fi + + printf "\r[$COUNT/$TOTAL] Downloading $mp3filename... " + + # Try primary URL first + if curl -s -f -o "$target_file" "${BASEURL}${mp3filename}" 2>/dev/null; then + : # Success + elif curl -s -f -o "$target_file" "${BACKUPURL}${mp3filename}" 2>/dev/null; then + : # Success from backup + else + ERRORS=$((ERRORS + 1)) + rm -f "$target_file" 2>/dev/null + fi + done + + echo "" + echo "" + echo "Download complete! Errors: $ERRORS" +else + echo "Audio files already downloaded ($AUDIO_COUNT files found)" +fi + +# Generate ground_truth.json from annotations +echo "" +echo "Generating ground_truth.json from annotations..." + +FIXTURES_DIR="$FIXTURES_DIR" DATASET_DIR="$DATASET_DIR" python3 << 'PYTHON_SCRIPT' +import json +import os +from pathlib import Path + +FIXTURES_DIR = os.environ.get("FIXTURES_DIR") +DATASET_DIR = os.environ.get("DATASET_DIR") + +# Parse annotations from the dataset +annotations_dir = Path(DATASET_DIR) / "annotations" / "tempo" +annotations_v2_dir = Path(DATASET_DIR) / "annotations_v2" / "tempo" +genre_dir = Path(DATASET_DIR) / "annotations" / "genre" + +tracks = [] + +# Prefer v2 annotations (crowdsourced corrections), fallback to v1 +for anno_file in sorted(annotations_dir.glob("*.bpm")): + filename = anno_file.stem # e.g., "1003173.LOFI" + + # Read BPM from annotation (format: single float value) + try: + bpm = float(anno_file.read_text().strip()) + except: + continue + + # Check for v2 annotation (crowdsourced correction) + v2_file = annotations_v2_dir / f"{filename}.bpm" + if v2_file.exists(): + try: + bpm = float(v2_file.read_text().strip()) + except: + pass + + # Read genre if available + genre = "unknown" + genre_file = genre_dir / f"{filename}.genre" + if genre_file.exists(): + try: + genre = genre_file.read_text().strip().lower().replace(" ", "-") + except: + pass + + tracks.append({ + "filename": f"{filename}.mp3", + "expected_bpm": round(bpm, 2), + "tolerance_percent": 2.0, + "genre": genre, + "notes": "" + }) + +# Create ground truth JSON +ground_truth = { + "version": 1, + "dataset": "giantsteps-tempo", + "description": "BPM ground truth from GiantSteps Tempo Dataset (crowdsourced corrections)", + "source": "https://github.com/GiantSteps/giantsteps-tempo-dataset", + "tracks": tracks +} + +output_path = Path(FIXTURES_DIR) / "ground_truth.json" +with open(output_path, "w") as f: + json.dump(ground_truth, f, indent=2) + +print(f"Generated ground_truth.json with {len(tracks)} tracks") +PYTHON_SCRIPT + +# Verify audio file count +FINAL_COUNT=$(find "$AUDIO_DIR" -name "*.mp3" 2>/dev/null | wc -l | tr -d ' ') + +echo "" +echo "=== Download Complete ===" +echo "" +echo "Audio files: $AUDIO_DIR ($FINAL_COUNT files)" +echo "Ground truth: $FIXTURES_DIR/ground_truth.json" +echo "Dataset repo: $DATASET_DIR" +echo "" +echo "Note: Audio files are ignored by .gitignore and won't be committed." +echo "" +echo "To run accuracy tests:" +echo " cargo test --package halo-dj --features accuracy-tests -- --nocapture"