diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e530b..7f5b670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Switched from hound to Symphonia. which supports a great deal more formats than just wav. -We now support flag, ogg, vorbis, mp3, alac, aac, and anything else Symphonia supports. +### Added + +MIDI-triggered sample playback has been added. Samples can be configured globally or per-song +with the following features: + +- **Velocity handling**: Configurable velocity sensitivity with optional fixed velocity override +- **Note Off behavior**: Samples can play to completion, stop immediately, or fade out on Note Off +- **Retrigger behavior**: Polyphonic mode allows layering, cut mode stops previous instances +- **Voice limits**: Global and per-sample voice limits with oldest-voice stealing +- **Output routing**: Samples can be routed to specific output channels via track mappings +- **In-memory preloading**: Samples are decoded and cached in memory for low-latency playback + +Sample triggering uses a fixed-latency scheduling system that ensures consistent trigger-to-audio +latency with zero jitter. At 256 sample buffer size (44.1kHz), latency is approximately 11.6ms +(~5.8ms scheduled delay + ~5.8ms output buffer). Cut transitions are sample-accurate - old +samples stop at exactly the same sample the new one starts, eliminating gaps. + +The audio engine has been refactored for lower latency and stability: + +- **Direct callback mode**: The CPAL callback now calls the mixer directly, eliminating the + intermediate ring buffer. This follows the pattern used by professional audio systems + (ASIO, CoreAudio, JACK) for lowest possible latency. +- Lock-free voice cancellation using atomic flags +- Channel-based source addition to decouple sample engine from mixer locks +- Inline cleanup of finished sources during mixing (simpler, no separate cleanup pass) +- Bounded source channel (capacity 64) to prevent unbounded memory growth +- Precomputed channel mappings at sample load time (no allocations during trigger) + +### Changed + +Updated cpal from 0.15.3 to 0.17.1 for improved ALSA handling. +(breaking) This may have changed device names. Please run mtrack devices to see if you need to update yours. + +Switched from hound to Symphonia, which supports a great deal more formats than just wav. +We now support flac, ogg, vorbis, mp3, alac, aac, and anything else Symphonia supports. + +### Fixed Fixed a bug where stopping too fast after playing could produce a hang. This is unlikely to have happened in a live scenario. diff --git a/Cargo.lock b/Cargo.lock index 0786c29..022f767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,18 @@ dependencies = [ "libc", ] +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.9.4", + "cfg-if", + "libc", +] + [[package]] name = "alsa-sys" version = "0.3.1" @@ -205,24 +217,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.106", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -253,6 +247,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -277,50 +280,18 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -[[package]] -name = "cc" -version = "1.2.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - [[package]] name = "cesu8" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.5.49" @@ -444,22 +415,16 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coreaudio-rs" -version = "0.11.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - -[[package]] -name = "coreaudio-sys" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" -dependencies = [ - "bindgen", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] @@ -485,12 +450,11 @@ dependencies = [ [[package]] name = "cpal" -version = "0.15.3" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ - "alsa", - "core-foundation-sys", + "alsa 0.10.0", "coreaudio-rs", "dasp_sample", "jni", @@ -499,11 +463,19 @@ dependencies = [ "mach2", "ndk", "ndk-context", - "oboe", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows 0.62.2", ] [[package]] @@ -581,6 +553,16 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -650,12 +632,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "find-msvc-tools" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" - [[package]] name = "fixedbitset" version = "0.4.2" @@ -752,12 +728,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "h2" version = "0.4.12" @@ -984,15 +954,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1030,16 +991,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.81" @@ -1073,16 +1024,6 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libyml" version = "0.0.5" @@ -1122,9 +1063,9 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -1156,7 +1097,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "554b64aee62310653a38da7ab34f96a41d9a94cfc5a6dc6f07872e07acb30e69" dependencies = [ - "alsa", + "alsa 0.9.1", "bitflags 1.3.2", "coremidi", "js-sys", @@ -1252,9 +1193,9 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "ndk" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.9.4", "jni-sys", @@ -1272,9 +1213,9 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "ndk-sys" -version = "0.5.0+25.2.9519653" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys", ] @@ -1379,26 +1320,92 @@ dependencies = [ ] [[package]] -name = "oboe" -version = "0.6.1" +name = "objc2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ - "jni", - "ndk", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", + "objc2-encode", ] [[package]] -name = "oboe-sys" -version = "0.6.1" +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.9.4", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "cc", + "bitflags 2.9.4", + "block2", + "libc", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -1891,12 +1898,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustfft" version = "6.4.1" @@ -2073,12 +2074,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "slab" version = "0.4.11" @@ -2908,32 +2903,33 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.54.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" dependencies = [ - "windows-core 0.54.0", + "windows-core 0.56.0", "windows-targets 0.52.6", ] [[package]] name = "windows" -version = "0.56.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", ] [[package]] -name = "windows-core" -version = "0.54.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-result", - "windows-targets 0.52.6", + "windows-core 0.62.2", ] [[package]] @@ -2942,12 +2938,36 @@ version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.56.0" @@ -2959,6 +2979,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-interface" version = "0.56.0" @@ -2970,12 +3001,33 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -2985,6 +3037,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -3078,6 +3148,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 19534ec..f99a3c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ exclude = [ [dependencies] clap = { version = "4.5.23", features = ["cargo", "derive"] } config = "0.15.6" -cpal = "0.15.3" +cpal = "0.17.1" rayon = "1.8.0" num_cpus = "1.16.0" duration-string = "0.5.2" diff --git a/README.md b/README.md index 240621d..fc87eb7 100644 --- a/README.md +++ b/README.md @@ -263,10 +263,11 @@ audio: # Run `mtrack devices` to see a list of the devices that mtrack recognizes. device: UltraLite-mk5 - # (Optional) The buffer size for decoded audio samples. This controls how many samples - # per channel are buffered internally before being returned. Larger values reduce I/O - # operations but use more memory. Defaults to 1024 samples per channel. - buffer_size: 1024 + # (Optional) The audio output buffer size in samples. This affects both playback stability + # and MIDI-triggered sample latency. Smaller values reduce latency but require more CPU. + # Trigger latency is approximately 2x this value (e.g., 256 samples = ~11.6ms at 44.1kHz). + # Defaults to 1024 samples. Common values: 128, 256, 512, 1024. + buffer_size: 256 # (Optional) The sample rate to use for the audio device. Defaults to 44100. sample_rate: 44100 @@ -605,6 +606,178 @@ the playlist. Again, refer to the example configuration above for the defaults f An starting TouchOSC file has been supplied [here](touchosc/mtrack.tosc). +## MIDI-Triggered Samples + +`mtrack` supports triggering audio samples via MIDI events. This is useful for playing one-shot sounds like clicks, cues, sound effects, or drum samples during a performance. Samples are preloaded into memory and transcoded at startup for low-latency playback. Trigger latency is approximately 2x the audio buffer size (e.g., ~11.6ms at 256 samples/44.1kHz). + +### Global vs Per-Song Samples + +Samples can be configured at two levels: + +1. **Global samples** - Defined in the main `mtrack.yaml` configuration file. These are available throughout the entire session. +2. **Per-song samples** - Defined in individual song configuration files. These override or extend the global configuration when that song is selected. + +### Sample Configuration + +Samples are defined in two parts: **sample definitions** (the audio files and their behavior) and **sample triggers** (the MIDI events that play them). + +#### Sample Definitions + +```yaml +samples: + # Each sample has a name that can be referenced by triggers. + kick: + # The audio file to play. Path is relative to the config file. + file: samples/kick.wav + + # Output channels to route this sample to (1-indexed). + output_channels: [3, 4] + + # Velocity handling configuration. + velocity: + # Mode can be: ignore, scale, or layers. + mode: scale + + # Behavior when Note Off is received: play_to_completion, stop, or fade. + note_off: play_to_completion + + # Behavior when retriggered while playing: cut or polyphonic. + retrigger: cut + + # Maximum concurrent voices for this sample (optional). + max_voices: 4 + + # Fade time in milliseconds when note_off is "fade" (default: 50). + fade_time_ms: 100 +``` + +#### Sample Triggers + +Triggers map MIDI events to samples. For Note On/Off events, only the channel and key are matched - the velocity from the incoming MIDI event is used for volume scaling or layer selection. + +```yaml +sample_triggers: + # Map a MIDI Note On event to a sample. + # The velocity from the incoming MIDI event is used for volume/layer selection. +- trigger: + type: note_on + channel: 10 + key: 60 # C3 + sample: kick + +- trigger: + type: note_on + channel: 10 + key: 62 # D3 + sample: snare +``` + +### Velocity Handling Modes + +#### Ignore Mode + +Ignores the MIDI velocity and plays at a fixed volume: + +```yaml +velocity: + mode: ignore + default: 100 # Fixed velocity (0-127), defaults to 100 +``` + +#### Scale Mode + +Scales the playback volume based on MIDI velocity (velocity/127): + +```yaml +velocity: + mode: scale +``` + +#### Layers Mode + +Selects different audio files based on velocity ranges. Useful for realistic drum sounds: + +```yaml +velocity: + mode: layers + # Optional: also scale volume within each layer. + scale: true + layers: + - range: [1, 60] # Soft hits + file: samples/snare_soft.wav + - range: [61, 100] # Medium hits + file: samples/snare_medium.wav + - range: [101, 127] # Hard hits + file: samples/snare_hard.wav +``` + +### Note Off Behavior + +Controls what happens when a MIDI Note Off event is received: + +- **`play_to_completion`** (default) - Ignores Note Off, lets the sample play to the end. +- **`stop`** - Immediately stops the sample. +- **`fade`** - Fades out the sample over the configured `fade_time_ms`. + +### Retrigger Behavior + +Controls what happens when a sample is triggered while it's already playing: + +- **`cut`** (default) - Stops the previous instance and starts a new one. +- **`polyphonic`** - Allows multiple instances to play simultaneously. + +### Voice Limits + +To prevent resource exhaustion, you can limit concurrent voices: + +```yaml +# Global limit for all samples. +max_sample_voices: 32 + +samples: + hihat: + # Per-sample limit (in addition to global limit). + max_voices: 8 +``` + +When limits are exceeded, the oldest voice is stopped to make room for new ones. + +### Stopping All Samples + +All triggered samples can be stopped via: + +- **OSC**: Send a message to `/mtrack/samples/stop` (configurable via `stop_samples` in OSC controller config) +- **gRPC**: Call the `StopSamples` RPC method + +### Per-Song Sample Overrides + +Individual songs can override or extend the global sample configuration: + +```yaml +# In a song's configuration file (e.g., songs/my-song/song.yaml) +name: My Song + +tracks: +- file: click.wav + name: click +- file: backing.wav + name: backing + +# Override global samples for this song. +samples: + kick: + file: custom_kick.wav # Use a different kick for this song + output_channels: [5, 6] + +# Add song-specific triggers. +sample_triggers: +- trigger: + type: note_on + channel: 10 + key: 64 + sample: kick +``` + ## Light Show Verification You can verify the syntax of a light show file using the `verify-light-show` command: diff --git a/examples/mtrack.yaml b/examples/mtrack.yaml index f3626ba..e20d117 100755 --- a/examples/mtrack.yaml +++ b/examples/mtrack.yaml @@ -282,3 +282,77 @@ track_mappings: keys: - 5 - 6 + +# MIDI-triggered sample configuration. +# Samples defined here are available globally and can be triggered via MIDI events. +# Per-song sample overrides can be defined in individual song configuration files. +samples: + # Sample definitions by name. These can be referenced by triggers below. + kick: + # The audio file to play. Path is relative to this config file. + file: songs/a-really-cool-song/click.wav + # Output channels to route this sample to (1-indexed). + output_channels: [3, 4] + # Velocity handling mode: ignore, scale, or layers. + velocity: + mode: scale + # Behavior on Note Off: play_to_completion, stop, or fade. + note_off: play_to_completion + # Behavior when retriggered while playing: cut or polyphonic. + retrigger: cut + # Maximum concurrent voices for this sample (optional). + max_voices: 4 + + snare: + file: songs/another-cool-song/click.wav + output_channels: [3, 4] + velocity: + mode: ignore + # Default velocity when mode is ignore (0-127). + default: 100 + note_off: play_to_completion + retrigger: polyphonic + max_voices: 8 + + # Example of velocity layers - different samples for different velocity ranges. + hihat: + output_channels: [3, 4] + velocity: + mode: layers + # Whether to also scale volume by velocity within each layer. + scale: true + layers: + - range: [1, 60] + file: songs/the-slow-one/click.wav + - range: [61, 100] + file: songs/sound-check/click.wav + - range: [101, 127] + file: songs/a-really-fast-one/click.wav + note_off: play_to_completion + retrigger: polyphonic + +# Sample triggers map MIDI events to sample names defined above. +sample_triggers: + # Trigger the "kick" sample on MIDI Note C3 (note 60) on channel 10. +- trigger: + type: note_on + channel: 10 + key: 60 + sample: kick + + # Trigger the "snare" sample on MIDI Note D3 (note 62) on channel 10. +- trigger: + type: note_on + channel: 10 + key: 62 + sample: snare + + # Trigger the "hihat" sample on MIDI Note F#3 (note 66) on channel 10. +- trigger: + type: note_on + channel: 10 + key: 66 + sample: hihat + +# Maximum number of concurrent sample voices globally. Defaults to 32. +max_sample_voices: 32 diff --git a/src/audio.rs b/src/audio.rs index 8070f14..635eac9 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -29,6 +29,9 @@ pub mod sample_source; // Re-export the format types for backward compatibility pub use format::{SampleFormat, TargetFormat}; +/// Type alias for the channel sender used to add sources to the mixer. +pub type SourceSender = crossbeam_channel::Sender; + pub trait Device: Any + fmt::Display + std::marker::Send + std::marker::Sync { /// Plays the given song through the audio interface, starting from a specific time. fn play_from( @@ -40,6 +43,18 @@ pub trait Device: Any + fmt::Display + std::marker::Send + std::marker::Sync { start_time: Duration, ) -> Result<(), Box>; + /// Gets the mixer for adding triggered samples. + /// Returns None if the device doesn't support triggered samples. + fn mixer(&self) -> Option> { + None + } + + /// Gets the source sender for adding triggered samples without lock contention. + /// Returns None if the device doesn't support triggered samples. + fn source_sender(&self) -> Option { + None + } + #[cfg(test)] fn to_mock(&self) -> Result, Box>; } diff --git a/src/audio/cpal.rs b/src/audio/cpal.rs index 1f6f3a9..fafa01e 100644 --- a/src/audio/cpal.rs +++ b/src/audio/cpal.rs @@ -24,7 +24,6 @@ use std::{ }; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use std::sync::atomic::AtomicUsize; use tracing::{error, info, span, Level}; use crate::audio::mixer::{ActiveSource as MixerActiveSource, AudioMixer}; @@ -36,118 +35,6 @@ use crate::{ }; use std::sync::Barrier; -/// Lock-free circular buffer for zero-copy audio streaming -struct CircularBuffer { - /// Backing buffer - buffer: Vec, - /// Capacity (must be power of 2) - capacity: usize, - /// Read position (consumer) - read_pos: AtomicUsize, - /// Write position (producer) - write_pos: AtomicUsize, -} - -impl CircularBuffer { - fn new(capacity: usize) -> Self { - // Round up to next power of 2 for efficient modulo - let cap = capacity.next_power_of_two(); - Self { - buffer: vec![0.0; cap], - capacity: cap, - read_pos: AtomicUsize::new(0), - write_pos: AtomicUsize::new(0), - } - } - - /// Get number of samples available to read - #[inline] - fn available(&self) -> usize { - let write = self.write_pos.load(Ordering::Acquire); - let read = self.read_pos.load(Ordering::Acquire); - // Both positions are in [0, capacity), so: - // - If write >= read: available = write - read - // - If write < read: wrapped, available = capacity - read + write - if write >= read { - write - read - } else { - self.capacity - read + write - } - } - - /// Get space available to write - #[inline] - fn space(&self) -> usize { - self.capacity - self.available() - 1 - } - - /// Write samples directly into buffer (zero-copy) - /// Returns number of samples actually written - fn write(&self, samples: &[f32]) -> usize { - let space = self.space(); - if space == 0 { - return 0; - } - let to_write = space.min(samples.len()); - let write = self.write_pos.load(Ordering::Acquire); - let mask = self.capacity - 1; - - // Write in one or two chunks (if wrap-around) - let first_chunk = (self.capacity - write).min(to_write); - unsafe { - let ptr = self.buffer.as_ptr().add(write) as *mut f32; - std::ptr::copy_nonoverlapping(samples.as_ptr(), ptr, first_chunk); - } - - if to_write > first_chunk { - let second_chunk = to_write - first_chunk; - unsafe { - let ptr = self.buffer.as_ptr() as *mut f32; - std::ptr::copy_nonoverlapping(samples.as_ptr().add(first_chunk), ptr, second_chunk); - } - } - - self.write_pos - .store((write + to_write) & mask, Ordering::Release); - to_write - } - - /// Read samples directly from buffer (zero-copy) - /// Returns number of samples actually read - fn read(&self, output: &mut [f32]) -> usize { - let available = self.available(); - if available == 0 { - return 0; - } - let to_read = available.min(output.len()); - let read = self.read_pos.load(Ordering::Acquire); - let mask = self.capacity - 1; - - // Read in one or two chunks (if wrap-around) - let first_chunk = (self.capacity - read).min(to_read); - unsafe { - let ptr = self.buffer.as_ptr().add(read); - std::ptr::copy_nonoverlapping(ptr, output.as_mut_ptr(), first_chunk); - } - - if to_read > first_chunk { - let second_chunk = to_read - first_chunk; - unsafe { - let ptr = self.buffer.as_ptr(); - std::ptr::copy_nonoverlapping( - ptr, - output.as_mut_ptr().add(first_chunk), - second_chunk, - ); - } - } - - self.read_pos - .store((read + to_read) & mask, Ordering::Release); - to_read - } -} - /// Global atomic counter for generating unique source IDs static SOURCE_ID_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -182,8 +69,6 @@ struct OutputManager { source_rx: crossbeam_channel::Receiver, /// Handle to the output thread (keeps it alive). output_thread: Option>, - /// Handle to the producer thread (fills ring buffer). - producer_thread: Option>, } impl fmt::Display for Device { @@ -199,34 +84,54 @@ impl fmt::Display for Device { } /// f32 callback: read directly into CPAL buffer (true zero-copy) -fn create_f32_callback( - ring: Arc, +/// Direct mixer callback for f32 output - no intermediate ring buffer +fn create_direct_f32_callback( + mixer: AudioMixer, + source_rx: crossbeam_channel::Receiver, + num_channels: u16, ) -> impl FnMut(&mut [f32], &cpal::OutputCallbackInfo) + Send + 'static { move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - let read = ring.read(data); - // Zero-fill any shortfall - data[read..].fill(0.0); + // Process any pending new sources (non-blocking) + while let Ok(new_source) = source_rx.try_recv() { + mixer.add_source(new_source); + } + + // Mix directly into the output buffer (cleanup happens inline) + let num_frames = data.len() / num_channels as usize; + mixer.process_into_output(data, num_frames); } } -/// Integer callback: read from ring and convert -fn create_single_thread_callback + std::fmt::Debug>( - ring: Arc, +/// Direct mixer callback for integer output - no intermediate ring buffer +fn create_direct_int_callback + std::fmt::Debug>( + mixer: AudioMixer, + source_rx: crossbeam_channel::Receiver, + num_channels: u16, ) -> impl FnMut(&mut [T], &cpal::OutputCallbackInfo) + Send + 'static where f32: cpal::FromSample, { + let mut temp_buffer: Vec = Vec::new(); + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { - let len = data.len(); - let mut temp = vec![0.0f32; len]; - let read = ring.read(&mut temp); + // Process any pending new sources (non-blocking) + while let Ok(new_source) = source_rx.try_recv() { + mixer.add_source(new_source); + } + + // Ensure temp buffer is large enough + if temp_buffer.len() < data.len() { + temp_buffer.resize(data.len(), 0.0); + } - // Zero-fill any shortfall - temp[read..].fill(0.0); + // Mix into temp buffer (cleanup happens inline) + let num_frames = data.len() / num_channels as usize; + let temp_slice = &mut temp_buffer[..data.len()]; + mixer.process_into_output(temp_slice, num_frames); // Convert to output format - for (dst, &src) in data.iter_mut().zip(temp.iter()) { - *dst = T::from_sample(src); + for (out, &sample) in data.iter_mut().zip(temp_slice.iter()) { + *out = T::from_sample(sample); } } } @@ -251,9 +156,6 @@ impl Drop for OutputManager { // Note: The channels will be automatically dropped when the struct is dropped // Wait for threads to finish - if let Some(thread) = self.producer_thread.take() { - let _ = thread.join(); - } if let Some(thread) = self.output_thread.take() { let _ = thread.join(); } @@ -263,7 +165,11 @@ impl Drop for OutputManager { impl OutputManager { /// Creates a new output manager. fn new(num_channels: u16, sample_rate: u32) -> Result> { - let (source_tx, source_rx) = crossbeam_channel::unbounded(); + // Bounded channel with capacity for typical use cases: + // - Songs with many tracks (8-16) + // - Rapid sample triggering + // If full, send blocks (back-pressure) rather than unbounded growth + let (source_tx, source_rx) = crossbeam_channel::bounded(64); let mixer = AudioMixer::new(num_channels, sample_rate); @@ -272,7 +178,6 @@ impl OutputManager { source_tx, source_rx, output_thread: None, - producer_thread: None, }; Ok(manager) @@ -285,64 +190,40 @@ impl OutputManager { } /// Starts the output thread that creates and manages the CPAL stream. + /// Uses direct callback mode - no intermediate ring buffer for lowest latency. fn start_output_thread( &mut self, device: cpal::Device, target_format: TargetFormat, + output_buffer_size: Option, ) -> Result<(), Box> { let mixer = self.mixer.clone(); let source_rx = self.source_rx.clone(); let num_channels = mixer.num_channels(); let sample_rate = mixer.sample_rate(); - // Create shared circular buffer (~100ms of audio) - let capacity_samples = (sample_rate as usize * num_channels as usize) / 10; - let ring = Arc::new(CircularBuffer::new(capacity_samples.max(1024))); - - // Producer thread: mix audio and write to ring buffer (zero-allocation) - let mixer_for_producer = mixer.clone(); - let source_rx_for_producer = source_rx.clone(); - let ring_for_producer = ring.clone(); - let producer_thread = thread::spawn(move || { - // Pre-allocate scratch buffer (reused for all blocks) - let block_frames = 512; // Small block size for low latency - let block_samples = block_frames * num_channels as usize; - let mut scratch = vec![0.0f32; block_samples]; - - loop { - // Process new sources - while let Ok(new_source) = source_rx_for_producer.try_recv() { - mixer_for_producer.add_source(new_source); - } - - // Check if ring has space for a block - if ring_for_producer.space() >= block_samples { - // Mix directly into scratch buffer (zero-allocation) - mixer_for_producer.process_into_output(&mut scratch, block_frames); - ring_for_producer.write(&scratch); - } else { - // Ring full, yield briefly - thread::sleep(Duration::from_micros(500)); - } - } - }); + // Use a barrier to ensure the stream is created before we return + let barrier = Arc::new(Barrier::new(2)); + let barrier_clone = barrier.clone(); // Start the output thread - create the stream inside the thread let output_thread = thread::spawn(move || { - // Create the CPAL stream - // Create the CPAL stream configuration - let CPAL choose appropriate buffer size + // Create the CPAL stream configuration + let buffer_size = match output_buffer_size { + Some(size) => cpal::BufferSize::Fixed(size), + None => cpal::BufferSize::Default, + }; let config = cpal::StreamConfig { channels: num_channels, - sample_rate: cpal::SampleRate(sample_rate), - buffer_size: cpal::BufferSize::Default, // Let CPAL choose the buffer size + sample_rate, + buffer_size, }; - // Create the output stream based on the target format - // Map SampleFormat to the appropriate CPAL stream type - + // Create the output stream with direct mixer callback (no ring buffer) let stream_result = if target_format.sample_format == crate::audio::SampleFormat::Float { - let mut callback = create_f32_callback(ring.clone()); + let mut callback = + create_direct_f32_callback(mixer.clone(), source_rx.clone(), num_channels); device.build_output_stream( &config, move |data: &mut [f32], info: &cpal::OutputCallbackInfo| { @@ -355,7 +236,11 @@ impl OutputManager { // For integer formats, we need to convert from f32 to the target integer type match target_format.bits_per_sample { 16 => { - let mut callback = create_single_thread_callback::(ring.clone()); + let mut callback = create_direct_int_callback::( + mixer.clone(), + source_rx.clone(), + num_channels, + ); device.build_output_stream( &config, move |data: &mut [i16], info: &cpal::OutputCallbackInfo| { @@ -366,7 +251,11 @@ impl OutputManager { ) } 32 => { - let mut callback = create_single_thread_callback::(ring.clone()); + let mut callback = create_direct_int_callback::( + mixer.clone(), + source_rx.clone(), + num_channels, + ); device.build_output_stream( &config, move |data: &mut [i32], info: &cpal::OutputCallbackInfo| { @@ -378,6 +267,7 @@ impl OutputManager { } _ => { error!("Unsupported bit depth for integer format"); + barrier_clone.wait(); return; } } @@ -388,9 +278,13 @@ impl OutputManager { Ok(stream) => { if let Err(e) = stream.play() { error!("Failed to start CPAL stream: {}", e); + barrier_clone.wait(); return; } - info!("CPAL output stream started successfully"); + info!("CPAL output stream started successfully (direct callback mode)"); + + // Signal that stream is ready + barrier_clone.wait(); // Keep the stream alive by waiting loop { @@ -399,12 +293,15 @@ impl OutputManager { } Err(e) => { error!("Failed to create CPAL stream: {}", e); + barrier_clone.wait(); } } }); + // Wait for stream to be created + barrier.wait(); + self.output_thread = Some(output_thread); - self.producer_thread = Some(producer_thread); Ok(()) } } @@ -466,7 +363,7 @@ impl Device { )?); devices.push(Device { - name: device.name()?, + name: device.id()?.to_string(), playback_delay: Duration::ZERO, max_channels, host_id, @@ -503,9 +400,12 @@ impl Device { let mut output_manager = OutputManager::new(device.max_channels, device.target_format.sample_rate)?; - // Start the output thread - output_manager - .start_output_thread(device.device.clone(), device.target_format.clone())?; + // Start the output thread with configured buffer size + output_manager.start_output_thread( + device.device.clone(), + device.target_format.clone(), + Some(config.buffer_size() as u32), + )?; device.output_manager = Arc::new(output_manager); device.audio_config = config; @@ -582,12 +482,15 @@ impl AudioDevice for Device { return Err("No sources found in song".into()); } - // Create unique IDs for each source and track them - let mut source_ids = Vec::new(); + // Create sources and track their finish flags (no locks needed for monitoring) + let mut source_finish_flags = Vec::new(); for source in channel_mapped_sources.into_iter() { let current_source_id = SOURCE_ID_COUNTER.fetch_add(1, Ordering::Relaxed); let source_channel_count = source.source_channel_count(); + let is_finished = Arc::new(AtomicBool::new(false)); + source_finish_flags.push(is_finished.clone()); + let active_source = MixerActiveSource { id: current_source_id, source, @@ -595,38 +498,31 @@ impl AudioDevice for Device { channel_mappings: Vec::new(), // Will be precomputed in add_source cached_source_channel_count: source_channel_count, cancel_handle: cancel_handle.clone(), // Clone for each source - is_finished: Arc::new(AtomicBool::new(false)), + is_finished, + start_at_sample: None, // Song sources play immediately + cancel_at_sample: None, // Song sources don't have scheduled cancellation }; - source_ids.push(current_source_id); self.output_manager.add_source(active_source)?; } - // Give the mixer a moment to process all the sources before starting monitoring - thread::sleep(Duration::from_millis(10)); - // Wait for either cancellation or natural completion let finished = Arc::new(AtomicBool::new(false)); // Start a background thread to monitor if all sources have finished + // This is completely lock-free - just checks atomic flags let finished_monitor = finished.clone(); - let mixer = self.output_manager.mixer.clone(); let cancel_handle_for_notify = cancel_handle.clone(); thread::spawn(move || { - // Poll the mixer to see if all sources for this play operation have finished loop { - let active_sources = mixer.get_active_sources(); - let sources = active_sources.read().unwrap(); - let has_active_sources = sources.iter().any(|source| { - let source_guard = source.lock().unwrap(); - source_ids.contains(&source_guard.id) - }); - drop(sources); + // Check if all sources have finished (lock-free) + let all_finished = source_finish_flags + .iter() + .all(|flag| flag.load(Ordering::Relaxed)); - if !has_active_sources { - // All sources for this play operation have finished + if all_finished { finished_monitor.store(true, Ordering::Relaxed); - cancel_handle_for_notify.notify(); // Wake up the waiting thread! + cancel_handle_for_notify.notify(); break; } @@ -639,6 +535,14 @@ impl AudioDevice for Device { Ok(()) } + fn mixer(&self) -> Option> { + Some(Arc::new(self.output_manager.mixer.clone())) + } + + fn source_sender(&self) -> Option { + Some(self.output_manager.source_tx.clone()) + } + #[cfg(test)] fn to_mock(&self) -> Result, Box> { Err("not a mock".into()) diff --git a/src/audio/mixer.rs b/src/audio/mixer.rs index da72815..904e1a7 100644 --- a/src/audio/mixer.rs +++ b/src/audio/mixer.rs @@ -16,7 +16,7 @@ use crate::audio::sample_source::ChannelMappedSampleSource; use std::collections::{HashMap, HashSet}; #[cfg(test)] use std::sync::atomic::AtomicUsize; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; #[cfg(test)] use std::time::Instant; @@ -30,6 +30,8 @@ pub struct AudioMixer { num_channels: u16, /// Sample rate sample_rate: u32, + /// Global sample counter for scheduling (increments each frame processed) + sample_counter: Arc, /// Performance monitoring (test only) #[cfg(test)] frame_count: Arc, @@ -56,6 +58,12 @@ pub struct ActiveSource { pub is_finished: Arc, /// Cancel handle for this source pub cancel_handle: crate::playsync::CancelHandle, + /// Sample count at which this source should start playing (for fixed-latency scheduling) + /// If None, the source plays immediately + pub start_at_sample: Option, + /// Sample count at which this source should stop playing (for scheduled cuts) + /// If None, the source plays until finished or cancelled + pub cancel_at_sample: Option>, } impl AudioMixer { @@ -65,6 +73,7 @@ impl AudioMixer { active_sources: Arc::new(RwLock::new(Vec::new())), num_channels, sample_rate, + sample_counter: Arc::new(AtomicU64::new(0)), #[cfg(test)] frame_count: Arc::new(AtomicUsize::new(0)), #[cfg(test)] @@ -74,6 +83,11 @@ impl AudioMixer { } } + /// Returns the current sample count (for scheduling triggered sources) + pub fn current_sample(&self) -> u64 { + self.sample_counter.load(Ordering::Relaxed) + } + /// Precomputes channel mappings for optimal performance during mixing fn precompute_channel_mappings( source: &dyn ChannelMappedSampleSource, @@ -258,6 +272,10 @@ impl AudioMixer { let channels = self.num_channels as usize; debug_assert_eq!(output.len(), num_frames * channels); + // Get current sample position for scheduling + let current_sample = self.sample_counter.load(Ordering::Relaxed); + let buffer_end_sample = current_sample + num_frames as u64; + // Clear the buffer once output.fill(0.0); @@ -282,13 +300,56 @@ impl AudioMixer { continue; } + // Check if this source has a scheduled cancellation time + if let Some(ref cancel_at) = active_source.cancel_at_sample { + let cancel_sample = cancel_at.load(Ordering::Relaxed); + if cancel_sample > 0 && current_sample >= cancel_sample { + // Scheduled cancellation time reached + active_source.is_finished.store(true, Ordering::Relaxed); + finished_source_ids.insert(active_source.id); + continue; + } + } + + // Check if this source should start playing yet (fixed-latency scheduling) + let start_frame = if let Some(start_at) = active_source.start_at_sample { + if start_at >= buffer_end_sample { + // Source hasn't reached its start time yet, skip entirely + continue; + } + // Calculate which frame in this buffer to start at + if start_at > current_sample { + (start_at - current_sample) as usize + } else { + 0 // Start time already passed, play from beginning of buffer + } + } else { + 0 // No scheduling, play immediately + }; + + // Check if this source has a scheduled end time within this buffer + let end_frame = if let Some(ref cancel_at) = active_source.cancel_at_sample { + let cancel_sample = cancel_at.load(Ordering::Relaxed); + if cancel_sample > 0 + && cancel_sample > current_sample + && cancel_sample < buffer_end_sample + { + // Source should stop partway through this buffer + (cancel_sample - current_sample) as usize + } else { + num_frames + } + } else { + num_frames + }; + let source_channel_count = active_source.cached_source_channel_count as usize; // Resize buffer if needed (should be rare) if source_frame_buffer.len() < source_channel_count { source_frame_buffer.resize(source_channel_count, 0.0); } - for frame_index in 0..num_frames { + for frame_index in start_frame..end_frame { match active_source .source .next_frame(&mut source_frame_buffer[..source_channel_count]) @@ -325,7 +386,11 @@ impl AudioMixer { } } - // Remove finished sources in a separate, quick write lock + // Increment the sample counter + self.sample_counter + .fetch_add(num_frames as u64, Ordering::Relaxed); + + // Clean up finished sources inline - we're the only accessor in direct callback mode if !finished_source_ids.is_empty() { let mut sources = self.active_sources.write().unwrap(); sources.retain(|source| { @@ -394,6 +459,8 @@ mod tests { cached_source_channel_count: 1, is_finished: Arc::new(AtomicBool::new(false)), cancel_handle: CancelHandle::new(), + start_at_sample: None, + cancel_at_sample: None, }; mixer.add_source(active_source); @@ -439,6 +506,8 @@ mod tests { cached_source_channel_count: 2, is_finished: Arc::new(AtomicBool::new(false)), cancel_handle: CancelHandle::new(), + start_at_sample: None, + cancel_at_sample: None, }; let active_source2 = ActiveSource { @@ -454,6 +523,8 @@ mod tests { cached_source_channel_count: 2, is_finished: Arc::new(AtomicBool::new(false)), cancel_handle: CancelHandle::new(), + start_at_sample: None, + cancel_at_sample: None, }; mixer.add_source(active_source1); @@ -497,6 +568,8 @@ mod tests { cached_source_channel_count: 32, is_finished: Arc::new(AtomicBool::new(false)), cancel_handle: CancelHandle::new(), + start_at_sample: None, + cancel_at_sample: None, }; mixer.add_source(active_source); diff --git a/src/audio/sample_source.rs b/src/audio/sample_source.rs index 02391ce..6cd9b79 100644 --- a/src/audio/sample_source.rs +++ b/src/audio/sample_source.rs @@ -24,10 +24,7 @@ mod tests; // Re-exports for use by other modules pub use channel_mapped::create_channel_mapped_sample_source; -#[cfg(test)] pub use channel_mapped::ChannelMappedSource; pub use factory::create_sample_source_from_file; -pub use traits::ChannelMappedSampleSource; - -#[cfg(test)] pub use memory::MemorySampleSource; +pub use traits::ChannelMappedSampleSource; diff --git a/src/audio/sample_source/memory.rs b/src/audio/sample_source/memory.rs index 7e03c7f..7759b7f 100644 --- a/src/audio/sample_source/memory.rs +++ b/src/audio/sample_source/memory.rs @@ -11,46 +11,68 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // -// These imports are used in the impl blocks below, but the linter may not see them -// in non-test mode due to #[cfg(test)] blocks -#[allow(unused_imports)] +use std::sync::Arc; + use super::error::SampleSourceError; -#[allow(unused_imports)] use super::traits::SampleSource; #[cfg(test)] use super::traits::SampleSourceTestExt; -/// A sample source that produces samples from memory -/// Useful for testing and future sample trigger functionality -#[cfg(test)] +/// A sample source that produces samples from memory. +/// Used for triggered samples and testing. pub struct MemorySampleSource { - samples: Vec, + /// The samples stored in an Arc for efficient cloning. + samples: Arc>, + /// Current playback position. current_index: usize, + /// Number of channels. channel_count: u16, + /// Sample rate. sample_rate: u32, + /// Volume scale factor (0.0 to 1.0). + volume: f32, } -#[cfg(test)] impl MemorySampleSource { - /// Creates a new memory sample source - pub fn new(samples: Vec, channel_count: u16, sample_rate: u32) -> Self { + /// Creates a new memory sample source from shared sample data. + /// This allows multiple playback instances to share the same sample data. + pub fn from_shared( + samples: Arc>, + channel_count: u16, + sample_rate: u32, + volume: f32, + ) -> Self { Self { samples, current_index: 0, channel_count, sample_rate, + volume, } } } #[cfg(test)] +impl MemorySampleSource { + /// Creates a new memory sample source (test only). + pub fn new(samples: Vec, channel_count: u16, sample_rate: u32) -> Self { + Self { + samples: Arc::new(samples), + current_index: 0, + channel_count, + sample_rate, + volume: 1.0, + } + } +} + impl SampleSource for MemorySampleSource { fn next_sample(&mut self) -> Result, SampleSourceError> { if self.current_index >= self.samples.len() { Ok(None) } else { - let sample = self.samples[self.current_index]; + let sample = self.samples[self.current_index] * self.volume; self.current_index += 1; Ok(Some(sample)) } @@ -65,16 +87,14 @@ impl SampleSource for MemorySampleSource { } fn bits_per_sample(&self) -> u16 { - 32 // Memory samples are typically 32-bit float + 32 // Memory samples are 32-bit float } fn sample_format(&self) -> crate::audio::SampleFormat { - crate::audio::SampleFormat::Float // Memory samples are float + crate::audio::SampleFormat::Float } fn duration(&self) -> Option { - // Calculate duration from sample count and sample rate - // For interleaved samples, we need to account for the channel count let total_samples = self.samples.len() as f64; let samples_per_channel = total_samples / self.channel_count as f64; let duration_secs = samples_per_channel / self.sample_rate as f64; @@ -82,6 +102,18 @@ impl SampleSource for MemorySampleSource { } } +impl Clone for MemorySampleSource { + fn clone(&self) -> Self { + Self { + samples: self.samples.clone(), + current_index: 0, // Start from the beginning + channel_count: self.channel_count, + sample_rate: self.sample_rate, + volume: self.volume, + } + } +} + #[cfg(test)] impl SampleSourceTestExt for MemorySampleSource { fn is_finished(&self) -> bool { diff --git a/src/config.rs b/src/config.rs index 7fccaa3..10024ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub mod midi; mod midi; mod player; mod playlist; +pub mod samples; mod song; mod statusevents; mod track; @@ -37,8 +38,15 @@ pub use self::dmx::Universe; pub use self::lighting::Lighting; pub use self::midi::Midi; pub use self::midi::MidiTransformer; +pub use self::midi::ToMidiEvent; pub use self::player::Player; pub use self::playlist::Playlist; +// Sample types are exported for external configuration +#[allow(unused_imports)] +pub use self::samples::{ + NoteOffBehavior, RetriggerBehavior, SampleDefinition, SampleTrigger, SamplesConfig, + VelocityConfig, VelocityLayer, VelocityMode, +}; pub use self::song::{LightShow, LightingShow, MidiPlayback, Song}; pub use self::statusevents::StatusEvents; pub use self::track::Track; diff --git a/src/config/controller.rs b/src/config/controller.rs index a9bad22..d87a230 100644 --- a/src/config/controller.rs +++ b/src/config/controller.rs @@ -27,6 +27,7 @@ const DEFAULT_OSC_NEXT: &str = "/mtrack/next"; const DEFAULT_OSC_STOP: &str = "/mtrack/stop"; const DEFAULT_OSC_ALL_SONGS: &str = "/mtrack/all_songs"; const DEFAULT_OSC_PLAYLIST: &str = "/mtrack/playlist"; +const DEFAULT_OSC_STOP_SAMPLES: &str = "/mtrack/samples/stop"; const DEFAULT_OSC_STATUS: &str = "/mtrack/status"; const DEFAULT_OSC_PLAYLIST_CURRENT: &str = "/mtrack/playlist/current"; const DEFAULT_OSC_PLAYLIST_CURRENT_SONG: &str = "/mtrack/playlist/current_song"; @@ -147,6 +148,8 @@ pub struct OscController { all_songs: Option, /// The OSC address to look for to switch back to the current playlist. playlist: Option, + /// The OSC address to look for to stop all triggered samples. + stop_samples: Option, /// The OSC address to broadcast to display the current player status. status: Option, /// The OSC address to broadcast the current playlist songs. @@ -169,6 +172,7 @@ impl OscController { stop: None, all_songs: None, playlist: None, + stop_samples: None, status: None, playlist_current: None, playlist_current_song: None, @@ -220,6 +224,13 @@ impl OscController { .unwrap_or(DEFAULT_OSC_PLAYLIST.to_string()) } + /// Gets the stop samples OSC address. + pub fn stop_samples(&self) -> String { + self.stop_samples + .clone() + .unwrap_or(DEFAULT_OSC_STOP_SAMPLES.to_string()) + } + /// Gets the player status. pub fn status(&self) -> String { self.status diff --git a/src/config/midi.rs b/src/config/midi.rs index 3c86dcb..005f4fb 100644 --- a/src/config/midi.rs +++ b/src/config/midi.rs @@ -155,7 +155,7 @@ impl ControlChangeMapper { } /// MIDI events that can be parsed from YAML. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Event { NoteOff(NoteOff), @@ -192,16 +192,31 @@ impl ToMidiEvent for Event { } /// A NoteOff event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct NoteOff { /// The channel the MIDI event belongs to. channel: u8, /// The key for the note off event. key: u8, /// The velocity of the note off event. + /// Optional for trigger matching; defaults to 0. + #[serde(default)] velocity: u8, } +#[cfg(test)] +impl NoteOff { + /// Gets the channel (1-indexed). + pub fn channel(&self) -> u8 { + self.channel + } + + /// Gets the key. + pub fn key(&self) -> u8 { + self.key + } +} + impl ToMidiEvent for NoteOff { fn to_midi_event(&self) -> Result, Box> { Ok(LiveEvent::Midi { @@ -215,16 +230,31 @@ impl ToMidiEvent for NoteOff { } /// A NoteOn event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct NoteOn { /// The channel the MIDI event belongs to. channel: u8, /// The key of the note on event. key: u8, /// The velocity of the note on event. + /// Optional for trigger matching; defaults to 0. + #[serde(default)] velocity: u8, } +#[cfg(test)] +impl NoteOn { + /// Gets the channel (1-indexed). + pub fn channel(&self) -> u8 { + self.channel + } + + /// Gets the key. + pub fn key(&self) -> u8 { + self.key + } +} + impl ToMidiEvent for NoteOn { fn to_midi_event(&self) -> Result, Box> { Ok(LiveEvent::Midi { @@ -238,7 +268,7 @@ impl ToMidiEvent for NoteOn { } /// An Aftertouch event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct Aftertouch { /// The channel the MIDI event belongs to. channel: u8, @@ -261,7 +291,7 @@ impl ToMidiEvent for Aftertouch { } /// A ControlChange event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct ControlChange { /// The channel the MIDI event belongs to. channel: u8, @@ -284,7 +314,7 @@ impl ToMidiEvent for ControlChange { } /// A ProgramChange event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct ProgramChange { /// The channel the MIDI event belongs to. channel: u8, @@ -304,7 +334,7 @@ impl ToMidiEvent for ProgramChange { } /// A ChannelAftertouch event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct ChannelAftertouch { /// The channel the MIDI event belongs to. channel: u8, @@ -324,7 +354,7 @@ impl ToMidiEvent for ChannelAftertouch { } /// A PitchBend event. -#[derive(Deserialize, Clone, Serialize)] +#[derive(Deserialize, Clone, Serialize, Debug, PartialEq, Eq)] pub struct PitchBend { /// The channel the MIDI event belongs to. channel: u8, diff --git a/src/config/player.rs b/src/config/player.rs index 0323914..44979eb 100644 --- a/src/config/player.rs +++ b/src/config/player.rs @@ -15,6 +15,7 @@ use super::audio::Audio; use super::controller::Controller; use super::dmx::Dmx; use super::midi::Midi; +use super::samples::{SampleDefinition, SampleTrigger, SamplesConfig, DEFAULT_MAX_SAMPLE_VOICES}; use super::statusevents::StatusEvents; use super::trackmappings::TrackMappings; use config::{Config, File}; @@ -22,7 +23,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::error::Error; use std::path::{Path, PathBuf}; -use tracing::error; +use tracing::{error, info}; /// The configuration for the multitrack player. #[derive(Deserialize)] @@ -49,6 +50,16 @@ pub struct Player { playlist: Option, /// The path to the song definitions. songs: String, + /// Inline sample definitions. + #[serde(default)] + samples: HashMap, + /// Path to external samples configuration file. + samples_file: Option, + /// Sample trigger mappings. + #[serde(default)] + sample_triggers: Vec, + /// Maximum number of concurrent sample voices globally. + max_sample_voices: Option, } impl Player { @@ -73,6 +84,10 @@ impl Player { status_events: None, playlist: None, songs: songs.to_string(), + samples: HashMap::new(), + samples_file: None, + sample_triggers: Vec::new(), + max_sample_voices: None, } } @@ -156,4 +171,43 @@ impl Player { }; player_path_directory.join(&self.songs) } + + /// Gets the samples configuration, merging inline definitions with any external file. + /// The player_path is used to resolve relative paths. + pub fn samples_config(&self, player_path: &Path) -> Result> { + let mut config = SamplesConfig::new( + self.samples.clone(), + self.sample_triggers.clone(), + self.max_sample_voices.unwrap_or(DEFAULT_MAX_SAMPLE_VOICES), + ); + + // Load external samples file if specified + if let Some(samples_file) = &self.samples_file { + let samples_path = if Path::new(samples_file).is_absolute() { + PathBuf::from(samples_file) + } else { + let player_dir = player_path.parent().unwrap_or(Path::new(".")); + player_dir.join(samples_file) + }; + + info!(path = ?samples_path, "Loading external samples configuration"); + + let external_config: SamplesConfig = Config::builder() + .add_source(File::from(samples_path.as_path())) + .build()? + .try_deserialize()?; + + // External config is loaded first, then inline config overrides it + let mut merged = external_config; + merged.merge(config); + config = merged; + } + + Ok(config) + } + + /// Gets the maximum sample voices limit. + pub fn max_sample_voices(&self) -> u32 { + self.max_sample_voices.unwrap_or(DEFAULT_MAX_SAMPLE_VOICES) + } } diff --git a/src/config/samples.rs b/src/config/samples.rs new file mode 100644 index 0000000..d5f5b35 --- /dev/null +++ b/src/config/samples.rs @@ -0,0 +1,580 @@ +// Copyright (C) 2026 Michael Wilson +// +// 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, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::midi; + +/// Default maximum number of concurrent sample voices globally. +pub const DEFAULT_MAX_SAMPLE_VOICES: u32 = 32; + +/// Default velocity value when velocity mode is set to ignore. +pub const DEFAULT_VELOCITY: u8 = 100; + +/// A YAML representation of a sample definition. +#[derive(Deserialize, Clone, Serialize, Debug)] +pub struct SampleDefinition { + /// The audio file for this sample (used when velocity mode is not "layers"). + file: Option, + + /// The output channels to route this sample to (1-indexed). + output_channels: Vec, + + /// Velocity handling configuration. + #[serde(default)] + velocity: VelocityConfig, + + /// Behavior when a Note Off event is received. + #[serde(default)] + note_off: NoteOffBehavior, + + /// Behavior when the sample is retriggered while still playing. + #[serde(default)] + retrigger: RetriggerBehavior, + + /// Maximum number of concurrent voices for this sample. + /// If not set, only the global limit applies. + max_voices: Option, + + /// Fade time in milliseconds for note_off: fade behavior. + #[serde(default = "default_fade_time_ms")] + fade_time_ms: u32, +} + +fn default_fade_time_ms() -> u32 { + 50 +} + +impl SampleDefinition { + /// Gets the output channels for this sample. + pub fn output_channels(&self) -> &[u16] { + &self.output_channels + } + + /// Gets the note-off behavior. + pub fn note_off(&self) -> NoteOffBehavior { + self.note_off + } + + /// Gets the retrigger behavior. + pub fn retrigger(&self) -> RetriggerBehavior { + self.retrigger + } + + /// Gets the maximum voices for this sample. + pub fn max_voices(&self) -> Option { + self.max_voices + } + + /// Gets the fade time in milliseconds. + /// Note: Fade behavior is not yet implemented; this config option is reserved for future use. + #[allow(dead_code)] + pub fn fade_time_ms(&self) -> u32 { + self.fade_time_ms + } + + /// Gets the file to play for a given velocity value. + /// Returns the file path and the volume scale factor (0.0 to 1.0). + pub fn file_for_velocity(&self, velocity: u8) -> Option<(&str, f32)> { + match &self.velocity.mode { + VelocityMode::Ignore => { + let volume = self.velocity.default.unwrap_or(DEFAULT_VELOCITY) as f32 / 127.0; + self.file.as_deref().map(|f| (f, volume)) + } + VelocityMode::Scale => { + let volume = velocity as f32 / 127.0; + self.file.as_deref().map(|f| (f, volume)) + } + VelocityMode::Layers => { + // Find the layer that matches this velocity + for layer in &self.velocity.layers { + if velocity >= layer.range[0] && velocity <= layer.range[1] { + let volume = if self.velocity.scale.unwrap_or(false) { + velocity as f32 / 127.0 + } else { + 1.0 + }; + return Some((&layer.file, volume)); + } + } + None + } + } + } + + /// Gets all files referenced by this sample definition (for preloading). + pub fn all_files(&self) -> Vec<&str> { + let mut files = Vec::new(); + if let Some(file) = &self.file { + files.push(file.as_str()); + } + for layer in &self.velocity.layers { + files.push(&layer.file); + } + files + } +} + +#[cfg(test)] +impl SampleDefinition { + /// Creates a new sample definition (test only). + #[allow(clippy::too_many_arguments)] + pub fn new( + file: Option, + output_channels: Vec, + velocity: VelocityConfig, + note_off: NoteOffBehavior, + retrigger: RetriggerBehavior, + max_voices: Option, + fade_time_ms: u32, + ) -> Self { + Self { + file, + output_channels, + velocity, + note_off, + retrigger, + max_voices, + fade_time_ms, + } + } + + /// Gets the audio file path (test only). + pub fn file(&self) -> Option<&str> { + self.file.as_deref() + } + + /// Gets the velocity configuration (test only). + pub fn velocity(&self) -> &VelocityConfig { + &self.velocity + } +} + +/// Configuration for velocity handling. +#[derive(Deserialize, Clone, Serialize, Debug, Default)] +pub struct VelocityConfig { + /// The velocity handling mode. + #[serde(default)] + mode: VelocityMode, + + /// Default velocity value when mode is "ignore". + default: Option, + + /// Whether to also scale volume by velocity when using layers. + scale: Option, + + /// Velocity layers (used when mode is "layers"). + #[serde(default)] + layers: Vec, +} + +#[cfg(test)] +impl VelocityConfig { + /// Creates a new velocity config with ignore mode (test only). + pub fn ignore(default: Option) -> Self { + Self { + mode: VelocityMode::Ignore, + default, + scale: None, + layers: Vec::new(), + } + } + + /// Creates a new velocity config with scale mode (test only). + pub fn scale() -> Self { + Self { + mode: VelocityMode::Scale, + default: None, + scale: None, + layers: Vec::new(), + } + } + + /// Creates a new velocity config with layers mode (test only). + pub fn with_layers(layers: Vec, scale: bool) -> Self { + Self { + mode: VelocityMode::Layers, + default: None, + scale: Some(scale), + layers, + } + } + + /// Gets the velocity mode (test only). + pub fn mode(&self) -> &VelocityMode { + &self.mode + } + + /// Gets the default velocity (test only). + pub fn default(&self) -> Option { + self.default + } + + /// Gets whether to scale volume in layers mode (test only). + pub fn scale_enabled(&self) -> bool { + self.scale.unwrap_or(false) + } + + /// Gets the velocity layers (test only). + pub fn layers(&self) -> &[VelocityLayer] { + &self.layers + } +} + +/// Velocity handling mode. +#[derive(Deserialize, Clone, Copy, Serialize, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VelocityMode { + /// Ignore velocity, play at default volume. + #[default] + Ignore, + /// Scale volume by velocity (velocity/127). + Scale, + /// Select different sample files based on velocity ranges. + Layers, +} + +/// A velocity layer that maps a velocity range to a sample file. +#[derive(Deserialize, Clone, Serialize, Debug)] +pub struct VelocityLayer { + /// The velocity range [min, max] inclusive (0-127). + range: [u8; 2], + + /// The audio file for this velocity layer. + file: String, +} + +#[cfg(test)] +impl VelocityLayer { + /// Creates a new velocity layer (test only). + pub fn new(range: [u8; 2], file: String) -> Self { + Self { range, file } + } + + /// Gets the velocity range (test only). + pub fn range(&self) -> [u8; 2] { + self.range + } + + /// Gets the file for this layer (test only). + pub fn file(&self) -> &str { + &self.file + } +} + +/// Behavior when a Note Off event is received for a playing sample. +#[derive(Deserialize, Clone, Copy, Serialize, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NoteOffBehavior { + /// Let the sample play to completion, ignoring Note Off. + #[default] + PlayToCompletion, + /// Immediately stop the sample on Note Off. + Stop, + /// Fade out the sample over a short duration on Note Off. + Fade, +} + +/// Behavior when a sample is triggered while it's already playing. +#[derive(Deserialize, Clone, Copy, Serialize, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RetriggerBehavior { + /// Stop the previous voice and start a new one. + #[default] + Cut, + /// Allow multiple voices to play simultaneously. + Polyphonic, +} + +/// A trigger that maps a MIDI event to a sample. +#[derive(Deserialize, Clone, Serialize, Debug)] +pub struct SampleTrigger { + /// The MIDI event that triggers the sample. + trigger: midi::Event, + + /// The name of the sample to trigger (references a SampleDefinition). + sample: String, +} + +impl SampleTrigger { + /// Gets the MIDI event that triggers the sample. + pub fn trigger(&self) -> &midi::Event { + &self.trigger + } + + /// Gets the name of the sample to trigger. + pub fn sample(&self) -> &str { + &self.sample + } +} + +#[cfg(test)] +impl SampleTrigger { + /// Creates a new sample trigger (test only). + pub fn new(trigger: midi::Event, sample: String) -> Self { + Self { trigger, sample } + } +} + +/// Global samples configuration that can be embedded in player config or loaded from a file. +#[derive(Deserialize, Clone, Serialize, Debug, Default)] +pub struct SamplesConfig { + /// Sample definitions by name. + #[serde(default)] + samples: HashMap, + + /// Sample triggers. + #[serde(default)] + sample_triggers: Vec, + + /// Maximum number of concurrent sample voices globally. + #[serde(default = "default_max_sample_voices")] + max_sample_voices: u32, +} + +fn default_max_sample_voices() -> u32 { + DEFAULT_MAX_SAMPLE_VOICES +} + +impl SamplesConfig { + /// Creates a new samples configuration. + pub fn new( + samples: HashMap, + sample_triggers: Vec, + max_sample_voices: u32, + ) -> Self { + Self { + samples, + sample_triggers, + max_sample_voices, + } + } + + /// Gets the sample definitions. + pub fn samples(&self) -> &HashMap { + &self.samples + } + + /// Gets the sample triggers. + pub fn sample_triggers(&self) -> &[SampleTrigger] { + &self.sample_triggers + } + + /// Merges another config into this one. The other config's values override. + pub fn merge(&mut self, other: SamplesConfig) { + // Merge sample definitions (other overrides) + for (name, definition) in other.samples { + self.samples.insert(name, definition); + } + + // Merge triggers - other's triggers override matching ones + // A trigger matches if it has the same MIDI event + for other_trigger in other.sample_triggers { + // Remove any existing trigger with the same MIDI event + self.sample_triggers + .retain(|t| t.trigger != other_trigger.trigger); + self.sample_triggers.push(other_trigger); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_velocity_ignore() { + let def = SampleDefinition::new( + Some("test.wav".to_string()), + vec![1, 2], + VelocityConfig::ignore(Some(100)), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Cut, + None, + 50, + ); + + let (file, volume) = def.file_for_velocity(50).unwrap(); + assert_eq!(file, "test.wav"); + assert!((volume - 100.0 / 127.0).abs() < 0.001); + + // Velocity value doesn't matter in ignore mode + let (_, volume2) = def.file_for_velocity(127).unwrap(); + assert!((volume - volume2).abs() < 0.001); + } + + #[test] + fn test_velocity_scale() { + let def = SampleDefinition::new( + Some("test.wav".to_string()), + vec![1, 2], + VelocityConfig::scale(), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Cut, + None, + 50, + ); + + let (file, volume) = def.file_for_velocity(64).unwrap(); + assert_eq!(file, "test.wav"); + assert!((volume - 64.0 / 127.0).abs() < 0.001); + + let (_, volume2) = def.file_for_velocity(127).unwrap(); + assert!((volume2 - 1.0).abs() < 0.001); + } + + #[test] + fn test_velocity_layers() { + let layers = vec![ + VelocityLayer::new([1, 60], "soft.wav".to_string()), + VelocityLayer::new([61, 100], "medium.wav".to_string()), + VelocityLayer::new([101, 127], "hard.wav".to_string()), + ]; + + let def = SampleDefinition::new( + None, + vec![1, 2], + VelocityConfig::with_layers(layers, false), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Polyphonic, + Some(4), + 50, + ); + + let (file, volume) = def.file_for_velocity(45).unwrap(); + assert_eq!(file, "soft.wav"); + assert!((volume - 1.0).abs() < 0.001); // No scaling + + let (file, _) = def.file_for_velocity(80).unwrap(); + assert_eq!(file, "medium.wav"); + + let (file, _) = def.file_for_velocity(120).unwrap(); + assert_eq!(file, "hard.wav"); + } + + #[test] + fn test_velocity_layers_with_scale() { + let layers = vec![ + VelocityLayer::new([1, 60], "soft.wav".to_string()), + VelocityLayer::new([61, 127], "hard.wav".to_string()), + ]; + + let def = SampleDefinition::new( + None, + vec![1, 2], + VelocityConfig::with_layers(layers, true), // Scale enabled + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Polyphonic, + None, + 50, + ); + + let (file, volume) = def.file_for_velocity(45).unwrap(); + assert_eq!(file, "soft.wav"); + assert!((volume - 45.0 / 127.0).abs() < 0.001); // Scaled to full range + + let (file, volume) = def.file_for_velocity(100).unwrap(); + assert_eq!(file, "hard.wav"); + assert!((volume - 100.0 / 127.0).abs() < 0.001); + } + + #[test] + fn test_all_files() { + let layers = vec![ + VelocityLayer::new([1, 60], "soft.wav".to_string()), + VelocityLayer::new([61, 127], "hard.wav".to_string()), + ]; + + let def = SampleDefinition::new( + Some("default.wav".to_string()), + vec![1, 2], + VelocityConfig::with_layers(layers, false), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Cut, + None, + 50, + ); + + let files = def.all_files(); + assert_eq!(files.len(), 3); + assert!(files.contains(&"default.wav")); + assert!(files.contains(&"soft.wav")); + assert!(files.contains(&"hard.wav")); + } + + #[test] + fn test_merge_configs() { + let mut config1 = SamplesConfig::new( + HashMap::from([ + ( + "kick".to_string(), + SampleDefinition::new( + Some("kick1.wav".to_string()), + vec![1], + VelocityConfig::ignore(None), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Cut, + None, + 50, + ), + ), + ( + "snare".to_string(), + SampleDefinition::new( + Some("snare1.wav".to_string()), + vec![2], + VelocityConfig::ignore(None), + NoteOffBehavior::PlayToCompletion, + RetriggerBehavior::Cut, + None, + 50, + ), + ), + ]), + vec![], + 32, + ); + + let config2 = SamplesConfig::new( + HashMap::from([( + "kick".to_string(), + SampleDefinition::new( + Some("kick2.wav".to_string()), // Override kick + vec![1, 2], + VelocityConfig::scale(), + NoteOffBehavior::Stop, + RetriggerBehavior::Polyphonic, + Some(4), + 100, + ), + )]), + vec![], + 32, + ); + + config1.merge(config2); + + // Kick should be overridden + assert_eq!( + config1.samples.get("kick").unwrap().file(), + Some("kick2.wav") + ); + // Snare should remain + assert_eq!( + config1.samples.get("snare").unwrap().file(), + Some("snare1.wav") + ); + } +} diff --git a/src/config/song.rs b/src/config/song.rs index 643a8ec..50188f2 100644 --- a/src/config/song.rs +++ b/src/config/song.rs @@ -11,7 +11,7 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . // -use std::{error::Error, io::Write, path::Path}; +use std::{collections::HashMap, error::Error, io::Write, path::Path}; use config::{Config, File}; use midly::live::LiveEvent; @@ -20,6 +20,7 @@ use tracing::info; use super::{ midi::{self, ToMidiEvent}, + samples::{SampleDefinition, SampleTrigger, SamplesConfig}, track::Track, }; @@ -40,10 +41,17 @@ pub struct Song { lighting: Option>, /// The associated tracks to play. tracks: Vec, + /// Song-specific sample definitions (overrides global samples with same name). + #[serde(default)] + samples: HashMap, + /// Song-specific sample trigger mappings (overrides global triggers with same MIDI event). + #[serde(default)] + sample_triggers: Vec, } impl Song { /// Creates a new song configuration. + #[allow(clippy::too_many_arguments)] pub fn new( name: &str, midi_event: Option, @@ -52,6 +60,8 @@ impl Song { light_shows: Option>, lighting: Option>, tracks: Vec, + samples: HashMap, + sample_triggers: Vec, ) -> Song { Song { name: name.to_string(), @@ -61,6 +71,8 @@ impl Song { light_shows, lighting, tracks, + samples, + sample_triggers, } } @@ -129,6 +141,16 @@ impl Song { pub fn tracks(&self) -> &Vec { &self.tracks } + + /// Gets the song-specific samples configuration. + /// Returns a SamplesConfig that can be merged with the global config. + pub fn samples_config(&self) -> SamplesConfig { + SamplesConfig::new( + self.samples.clone(), + self.sample_triggers.clone(), + 0, // Per-song config doesn't set global max_voices + ) + } } // A YAML representation of MIDI files with channel exclusions. diff --git a/src/controller/grpc.rs b/src/controller/grpc.rs index 315221f..6f1ff7b 100644 --- a/src/controller/grpc.rs +++ b/src/controller/grpc.rs @@ -25,7 +25,8 @@ use crate::{ Cue, GetActiveEffectsRequest, GetActiveEffectsResponse, GetCuesRequest, GetCuesResponse, NextRequest, NextResponse, PlayFromRequest, PlayRequest, PlayResponse, PreviousRequest, PreviousResponse, StatusRequest, StatusResponse, StopRequest, StopResponse, - SwitchToPlaylistRequest, SwitchToPlaylistResponse, FILE_DESCRIPTOR_SET, + StopSamplesRequest, StopSamplesResponse, SwitchToPlaylistRequest, SwitchToPlaylistResponse, + FILE_DESCRIPTOR_SET, }, }; @@ -248,6 +249,14 @@ impl PlayerService for PlayerServer { Ok(Response::new(GetActiveEffectsResponse { active_effects })) } + + async fn stop_samples( + &self, + _: Request, + ) -> Result, Status> { + self.player.stop_samples(); + Ok(Response::new(StopSamplesResponse {})) + } } #[cfg(test)] diff --git a/src/controller/midi.rs b/src/controller/midi.rs index bb34213..307b875 100644 --- a/src/controller/midi.rs +++ b/src/controller/midi.rs @@ -97,6 +97,9 @@ impl super::Driver for Driver { } }; + // Process triggered samples (synchronous, minimal latency) + player.process_sample_trigger(&raw_event); + let event = match LiveEvent::parse(&raw_event) { Ok(event) => event, Err(e) => { diff --git a/src/controller/osc.rs b/src/controller/osc.rs index 434c630..fb4c365 100644 --- a/src/controller/osc.rs +++ b/src/controller/osc.rs @@ -70,6 +70,8 @@ pub(super) struct OscEvents { all_songs: Matcher, /// The OSC address to look for to switch back to the current playlist. playlist: Matcher, + /// The OSC address to look for to stop all triggered samples. + stop_samples: Matcher, /// The OSC address to use to broadcast the player status. status: String, /// The OSC address to use to broadcast the current playlist. @@ -104,6 +106,7 @@ impl Driver { stop: Matcher::new(config.stop().as_str())?, all_songs: Matcher::new(config.all_songs().as_str())?, playlist: Matcher::new(config.playlist().as_str())?, + stop_samples: Matcher::new(config.stop_samples().as_str())?, status: config.status(), playlist_current: config.playlist_current(), playlist_current_song: config.playlist_current_song(), @@ -363,6 +366,9 @@ impl Driver { } else if osc_events.playlist.match_address(&address) { player.switch_to_playlist().await; recognized_event = true; + } else if osc_events.stop_samples.match_address(&address) { + player.stop_samples(); + recognized_event = true; } Ok(recognized_event) diff --git a/src/dmx/engine.rs b/src/dmx/engine.rs index cf38d22..ff5d969 100644 --- a/src/dmx/engine.rs +++ b/src/dmx/engine.rs @@ -1417,6 +1417,8 @@ mod test { None, None, // No lighting shows for this test vec![], + std::collections::HashMap::new(), + Vec::new(), ); // Test that the song config has lighting @@ -1615,6 +1617,8 @@ mod test { None, None, // No lighting for this test vec![], + std::collections::HashMap::new(), + Vec::new(), ); let song = crate::songs::Song::new(temp_path, &song_config)?; diff --git a/src/lib.rs b/src/lib.rs index dc07c5d..8e6b24b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod player; pub mod playlist; pub mod playsync; pub mod proto; +pub mod samples; pub mod songs; #[cfg(test)] pub mod testutil; diff --git a/src/main.rs b/src/main.rs index 5f666f6..987ad18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod player; mod playlist; mod playsync; mod proto; +mod samples; mod songs; #[cfg(test)] mod testutil; diff --git a/src/player.rs b/src/player.rs index c30a80f..fcee62a 100644 --- a/src/player.rs +++ b/src/player.rs @@ -19,7 +19,7 @@ use std::{ path::Path, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Barrier, + Arc, Barrier, RwLock, }, thread, time::{Duration, SystemTime}, @@ -30,6 +30,7 @@ use tokio::{ }; use tracing::{error, info, span, warn, Level, Span}; +use crate::samples::SampleEngine; use crate::songs::Songs; use crate::{ audio, config, dmx, midi, @@ -54,6 +55,8 @@ pub struct Player { midi_device: Option>, /// The DMX engine to use. dmx_engine: Option>, + /// The sample engine for MIDI-triggered samples. + sample_engine: Option>>, /// The playlist to use. playlist: Arc, /// The all songs playlist. @@ -95,11 +98,38 @@ impl Player { StatusEvents::new(config.status_events()) }); + // Initialize the sample engine if the audio device supports it + let sample_engine = match (device.mixer(), device.source_sender()) { + (Some(mixer), Some(source_tx)) => { + let max_voices = config.max_sample_voices(); + let buffer_size = config.audio().map(|a| a.buffer_size()).unwrap_or(1024); + let mut engine = SampleEngine::new(mixer, source_tx, max_voices, buffer_size); + + // Load global samples config if available + if let Some(base_path) = base_path { + match config.samples_config(base_path) { + Ok(samples_config) => { + if let Err(e) = engine.load_global_config(&samples_config, base_path) { + warn!(error = %e, "Failed to load global samples config"); + } + } + Err(e) => { + warn!(error = %e, "Failed to parse samples config"); + } + } + } + + Some(Arc::new(RwLock::new(engine))) + } + _ => None, + }; + let player = Player { device, mappings: Arc::new(config.track_mappings().clone()), midi_device, dmx_engine, + sample_engine, playlist, all_songs: playlist::from_songs(songs)?, use_all_songs: Arc::new(AtomicBool::new(false)), @@ -157,6 +187,52 @@ impl Player { self.midi_device.clone() } + /// Processes a MIDI event for triggered samples. + /// This should be called by the MIDI controller when events are received. + /// Uses std::sync::RwLock for minimal latency (no async overhead). + pub fn process_sample_trigger(&self, raw_event: &[u8]) { + if let Some(ref sample_engine) = self.sample_engine { + let engine = sample_engine.read().unwrap(); + engine.process_midi_event(raw_event); + } + } + + /// Loads the sample configuration for a song. + /// This preloads samples for the song so they're ready for instant playback. + /// Note: Active voices continue playing through song transitions. + fn load_song_samples(&self, song: &Song) { + if let Some(ref sample_engine) = self.sample_engine { + // Load the new song's sample config if it has one + let samples_config = song.samples_config(); + if !samples_config.samples().is_empty() || !samples_config.sample_triggers().is_empty() + { + let mut engine = sample_engine.write().unwrap(); + if let Err(e) = engine.load_song_config(samples_config, song.base_path()) { + warn!( + song = song.name(), + error = %e, + "Failed to load song sample config" + ); + } else { + info!( + song = song.name(), + samples = samples_config.samples().len(), + triggers = samples_config.sample_triggers().len(), + "Loaded song sample config" + ); + } + } + } + } + + /// Stops all triggered sample playback. + pub fn stop_samples(&self) { + if let Some(ref sample_engine) = self.sample_engine { + let engine = sample_engine.read().unwrap(); + engine.stop_all(); + } + } + /// Gets the DMX engine currently in use by the player (for testing). #[cfg(test)] pub fn dmx_engine(&self) -> Option> { @@ -255,6 +331,9 @@ impl Player { return Ok(None); } + // Load samples for this song (if not already loaded) + self.load_song_samples(&song); + // Validate lighting shows before starting playback if let Some(ref dmx_engine) = self.dmx_engine { if let Err(e) = dmx_engine.validate_song_lighting(&song) { @@ -468,7 +547,9 @@ impl Player { ); return current; } - Player::next_and_emit(self.midi_device.clone(), playlist) + let song = Player::next_and_emit(self.midi_device.clone(), playlist); + self.load_song_samples(&song); + song } /// Prev goes to the previous entry in the playlist. @@ -483,7 +564,9 @@ impl Player { ); return current; } - Player::prev_and_emit(self.midi_device.clone(), playlist) + let song = Player::prev_and_emit(self.midi_device.clone(), playlist); + self.load_song_samples(&song); + song } /// Stop will stop a song if a song is playing. @@ -800,6 +883,8 @@ mod test { .to_string(), )]), vec![], + HashMap::new(), + Vec::new(), ); // Create a lighting config with valid groups (but not "invalid_group") @@ -897,6 +982,8 @@ mod test { wav_file.file_name().unwrap().to_str().unwrap(), Some(1), )], + HashMap::new(), + Vec::new(), ); // Create a lighting config with the valid group @@ -1010,6 +1097,8 @@ mod test { wav_file.file_name().unwrap().to_str().unwrap(), Some(1), )], + HashMap::new(), + Vec::new(), ); // Create DMX config without lighting (or with lighting, shouldn't matter) @@ -1093,6 +1182,8 @@ mod test { .to_string(), )]), vec![], + HashMap::new(), + Vec::new(), ); // Create a lighting config with valid groups (but not the invalid ones) diff --git a/src/proto/player/v1/player.proto b/src/proto/player/v1/player.proto index d894638..6295991 100644 --- a/src/proto/player/v1/player.proto +++ b/src/proto/player/v1/player.proto @@ -133,6 +133,12 @@ message GetActiveEffectsResponse { string active_effects = 1; } +// StopSamplesRequest is the message for requesting the player to stop all triggered samples. +message StopSamplesRequest {} + +// StopSamplesResponse is the response message after stopping triggered samples. +message StopSamplesResponse {} + // PlayerService is a service for controlling the mtrack player. service PlayerService { // Play will play the current song in the playlist if no other songs @@ -163,4 +169,7 @@ service PlayerService { // GetActiveEffects returns a formatted string listing all active lighting effects. rpc GetActiveEffects(GetActiveEffectsRequest) returns (GetActiveEffectsResponse); + + // StopSamples will stop all triggered samples that are currently playing. + rpc StopSamples(StopSamplesRequest) returns (StopSamplesResponse); } \ No newline at end of file diff --git a/src/samples.rs b/src/samples.rs new file mode 100644 index 0000000..506bd2f --- /dev/null +++ b/src/samples.rs @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Michael Wilson +// +// 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, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// + +//! MIDI-triggered sample playback system. +//! +//! This module provides: +//! - Sample loading and caching (in-memory for zero-latency playback) +//! - MIDI event to sample trigger matching +//! - Voice management with polyphony limits +//! - Integration with the audio mixer + +mod engine; +mod loader; +mod voice; + +pub use engine::SampleEngine; + +// These types are exported for potential external use and testing +#[allow(unused_imports)] +pub use loader::{LoadedSample, SampleLoader}; +#[allow(unused_imports)] +pub use voice::{Voice, VoiceManager}; diff --git a/src/samples/engine.rs b/src/samples/engine.rs new file mode 100644 index 0000000..459f4fd --- /dev/null +++ b/src/samples/engine.rs @@ -0,0 +1,646 @@ +// Copyright (C) 2026 Michael Wilson +// +// 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, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// + +//! Main sample engine that coordinates trigger matching, sample loading, and playback. + +use std::collections::HashMap; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; + +use midly::live::LiveEvent; +use midly::MidiMessage; +use tracing::{debug, error, info, warn}; + +use super::loader::{LoadedSample, SampleLoader}; +use super::voice::{Voice, VoiceManager}; +use crate::audio::sample_source::ChannelMappedSource; +use crate::config::samples::{NoteOffBehavior, SampleDefinition, SampleTrigger, SamplesConfig}; +use crate::config::ToMidiEvent; +use crate::playsync::CancelHandle; + +/// Global source ID counter for the mixer. +static NEXT_SOURCE_ID: AtomicU64 = AtomicU64::new(1); + +/// Precomputed data for a loaded sample file, avoiding allocations during trigger. +struct PrecomputedSampleData { + /// The loaded sample audio data. + loaded: LoadedSample, + /// Precomputed channel labels for ChannelMappedSource. + channel_labels: Vec>, + /// Precomputed track mappings for the mixer. + track_mappings: HashMap>, +} + +/// Active sample definition with preloaded audio data. +struct ActiveSample { + /// The sample definition from config. + definition: SampleDefinition, + /// Loaded audio data by file path, with precomputed mappings. + loaded_files: HashMap, + /// Base path for resolving relative file paths. + base_path: PathBuf, +} + +/// A trigger definition with pre-converted MIDI event for matching. +struct ActiveTrigger { + /// The MIDI event to match (as LiveEvent for efficient comparison). + midi_event: LiveEvent<'static>, + /// The sample name to trigger. + sample_name: String, +} + +/// The sample engine manages MIDI-triggered sample playback. +pub struct SampleEngine { + /// Sample loader for loading audio files. + loader: SampleLoader, + /// Active sample definitions by name. + samples: HashMap, + /// Active triggers. + triggers: Vec, + /// Voice manager for polyphony. + voice_manager: RwLock, + /// Channel for adding sources without lock contention. + source_tx: crate::audio::SourceSender, + /// Reference to mixer for sample scheduling. + mixer: std::sync::Arc, + /// Fixed delay in samples for consistent trigger latency. + fixed_delay_samples: u64, +} + +impl SampleEngine { + /// Creates a new sample engine. + /// + /// The `buffer_size` is used to calculate the minimum fixed delay for consistent + /// trigger latency. The delay is set to 2x the buffer size to ensure samples are + /// always scheduled ahead of the current mixing position. + pub fn new( + mixer: std::sync::Arc, + source_tx: crate::audio::SourceSender, + max_voices: u32, + buffer_size: usize, + ) -> Self { + let sample_rate = mixer.sample_rate(); + // Fixed delay of 1x buffer size ensures the sample is scheduled ahead of current mixing + // This accounts for async channel delivery between trigger and audio callback + // At 256 samples @ 44.1kHz: ~5.8ms latency + let fixed_delay_samples = buffer_size as u64; + Self { + loader: SampleLoader::new(sample_rate), + samples: HashMap::new(), + triggers: Vec::new(), + voice_manager: RwLock::new(VoiceManager::new(max_voices)), + source_tx, + mixer, + fixed_delay_samples, + } + } + + /// Loads global sample configuration. + /// This should be called at startup with the global config. + pub fn load_global_config( + &mut self, + config: &SamplesConfig, + base_path: &Path, + ) -> Result<(), Box> { + info!( + samples = config.samples().len(), + triggers = config.sample_triggers().len(), + "Loading global samples configuration" + ); + + // Load sample definitions + for (name, definition) in config.samples() { + self.load_sample(name, definition, base_path)?; + } + + // Load triggers + for trigger in config.sample_triggers() { + self.add_trigger(trigger)?; + } + + info!( + loaded_samples = self.samples.len(), + loaded_triggers = self.triggers.len(), + memory_kb = self.loader.total_memory_usage() / 1024, + "Global samples loaded" + ); + + Ok(()) + } + + /// Loads per-song sample configuration. + /// This merges with global config (song config overrides global). + pub fn load_song_config( + &mut self, + config: &SamplesConfig, + base_path: &Path, + ) -> Result<(), Box> { + if config.samples().is_empty() && config.sample_triggers().is_empty() { + return Ok(()); + } + + info!( + samples = config.samples().len(), + triggers = config.sample_triggers().len(), + "Loading song samples configuration" + ); + + // Load/override sample definitions + for (name, definition) in config.samples() { + self.load_sample(name, definition, base_path)?; + } + + // Add/override triggers + for trigger in config.sample_triggers() { + self.add_trigger(trigger)?; + } + + Ok(()) + } + + /// Loads a sample definition and preloads its audio data. + fn load_sample( + &mut self, + name: &str, + definition: &SampleDefinition, + base_path: &Path, + ) -> Result<(), Box> { + // Load all files referenced by this definition + let raw_loaded_files = self.loader.load_definition(definition, base_path)?; + + // Precompute channel labels and track mappings for each loaded file + // This avoids string formatting and HashMap allocation on every trigger + let output_channels = definition.output_channels(); + let loaded_files: HashMap = raw_loaded_files + .into_iter() + .map(|(path, loaded)| { + let channel_labels: Vec> = (0..loaded.channel_count()) + .map(|_| { + output_channels + .iter() + .map(|ch| format!("__sample_out_{}", ch)) + .collect() + }) + .collect(); + + let track_mappings: HashMap> = output_channels + .iter() + .map(|ch| (format!("__sample_out_{}", ch), vec![*ch])) + .collect(); + + ( + path, + PrecomputedSampleData { + loaded, + channel_labels, + track_mappings, + }, + ) + }) + .collect(); + + // Set up per-sample voice limit if configured + if let Some(max_voices) = definition.max_voices() { + let mut vm = self.voice_manager.write().unwrap(); + vm.set_sample_limit(name, max_voices); + } + + self.samples.insert( + name.to_string(), + ActiveSample { + definition: definition.clone(), + loaded_files, + base_path: base_path.to_path_buf(), + }, + ); + + debug!(name, "Sample loaded"); + Ok(()) + } + + /// Adds a trigger mapping. + fn add_trigger(&mut self, trigger: &SampleTrigger) -> Result<(), Box> { + let midi_event = trigger.trigger().to_midi_event()?; + + // Remove any existing trigger with the same MIDI event + self.triggers.retain(|t| t.midi_event != midi_event); + + self.triggers.push(ActiveTrigger { + midi_event, + sample_name: trigger.sample().to_string(), + }); + + debug!(sample = trigger.sample(), "Trigger added"); + Ok(()) + } + + /// Processes an incoming MIDI event. + /// This is the main entry point called when MIDI data is received. + pub fn process_midi_event(&self, raw_event: &[u8]) { + let event = match LiveEvent::parse(raw_event) { + Ok(e) => e, + Err(e) => { + debug!(error = ?e, "Failed to parse MIDI event"); + return; + } + }; + + // Check for Note Off events first (for voice management) + if let LiveEvent::Midi { channel, message } = &event { + match message { + MidiMessage::NoteOff { key, .. } => { + self.handle_note_off(u8::from(*key), u8::from(*channel) + 1); + } + MidiMessage::NoteOn { key, vel } if u8::from(*vel) == 0 => { + // Note On with velocity 0 is equivalent to Note Off + self.handle_note_off(u8::from(*key), u8::from(*channel) + 1); + } + _ => {} + } + } + + // Check against triggers (hot path - minimal overhead) + for trigger in &self.triggers { + if self.matches_trigger(&event, &trigger.midi_event) { + let velocity = self.extract_velocity(&event); + self.trigger_sample(&trigger.sample_name, velocity, &event); + } + } + } + + /// Checks if a MIDI event matches a trigger. + fn matches_trigger(&self, event: &LiveEvent, trigger_event: &LiveEvent) -> bool { + // For now, we do exact matching on the event type and channel/key + // but ignore velocity in the match (velocity is used for sample selection) + match (event, trigger_event) { + ( + LiveEvent::Midi { + channel: c1, + message: m1, + }, + LiveEvent::Midi { + channel: c2, + message: m2, + }, + ) => { + if c1 != c2 { + return false; + } + match (m1, m2) { + (MidiMessage::NoteOn { key: k1, .. }, MidiMessage::NoteOn { key: k2, .. }) => { + k1 == k2 + } + ( + MidiMessage::NoteOff { key: k1, .. }, + MidiMessage::NoteOff { key: k2, .. }, + ) => k1 == k2, + ( + MidiMessage::Controller { + controller: c1, + value: v1, + }, + MidiMessage::Controller { + controller: c2, + value: v2, + }, + ) => c1 == c2 && v1 == v2, + ( + MidiMessage::ProgramChange { program: p1 }, + MidiMessage::ProgramChange { program: p2 }, + ) => p1 == p2, + ( + MidiMessage::ChannelAftertouch { vel: v1 }, + MidiMessage::ChannelAftertouch { vel: v2 }, + ) => v1 == v2, + ( + MidiMessage::Aftertouch { key: k1, vel: v1 }, + MidiMessage::Aftertouch { key: k2, vel: v2 }, + ) => k1 == k2 && v1 == v2, + (MidiMessage::PitchBend { bend: b1 }, MidiMessage::PitchBend { bend: b2 }) => { + b1 == b2 + } + _ => false, + } + } + _ => false, + } + } + + /// Extracts velocity from a MIDI event. + fn extract_velocity(&self, event: &LiveEvent) -> u8 { + match event { + LiveEvent::Midi { message, .. } => match message { + MidiMessage::NoteOn { vel, .. } => u8::from(*vel), + MidiMessage::NoteOff { vel, .. } => u8::from(*vel), + MidiMessage::Aftertouch { vel, .. } => u8::from(*vel), + MidiMessage::ChannelAftertouch { vel } => u8::from(*vel), + MidiMessage::Controller { value, .. } => u8::from(*value), + _ => 127, // Default to max for events without velocity + }, + _ => 127, + } + } + + /// Extracts note and channel from a MIDI event for Note Off matching. + fn extract_note_channel(&self, event: &LiveEvent) -> Option<(u8, u8)> { + match event { + LiveEvent::Midi { + channel, + message: MidiMessage::NoteOn { key, .. } | MidiMessage::NoteOff { key, .. }, + } => Some((u8::from(*key), u8::from(*channel) + 1)), + _ => None, + } + } + + /// Triggers a sample by name with the given velocity. + fn trigger_sample(&self, sample_name: &str, velocity: u8, event: &LiveEvent) { + let sample = match self.samples.get(sample_name) { + Some(s) => s, + None => { + warn!(sample = sample_name, "Sample not found"); + return; + } + }; + + // Get the file to play based on velocity + let (file_path, volume) = match sample.definition.file_for_velocity(velocity) { + Some((file, vol)) => { + let path = if Path::new(file).is_absolute() { + PathBuf::from(file) + } else { + sample.base_path.join(file) + }; + (path, vol) + } + None => { + warn!( + sample = sample_name, + velocity, "No sample file for velocity" + ); + return; + } + }; + + // Get the precomputed sample data + let precomputed = match sample.loaded_files.get(&file_path) { + Some(p) => p, + None => { + error!( + sample = sample_name, + path = ?file_path, + "Sample file not loaded" + ); + return; + } + }; + + // Create a new source for playback + let source = precomputed.loaded.create_source(volume); + let source_id = NEXT_SOURCE_ID.fetch_add(1, Ordering::SeqCst); + + // Use precomputed channel labels and track mappings (no allocations!) + let channel_mapped = ChannelMappedSource::new( + Box::new(source), + precomputed.channel_labels.clone(), + precomputed.loaded.channel_count(), + ); + let track_mappings = precomputed.track_mappings.clone(); + + // Extract note/channel for Note Off tracking + let (trigger_note, trigger_channel) = self + .extract_note_channel(event) + .map(|(n, c)| (Some(n), Some(c))) + .unwrap_or((None, None)); + + // Create a per-source cancel handle and scheduled cancel time for lock-free voice stopping + let source_cancel_handle = CancelHandle::new(); + let source_cancel_at_sample = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); // 0 = no scheduled cancel + + // Create voice entry with its own cancel handle and scheduled cancel time + let voice = Voice::new( + sample_name.to_string(), + trigger_note, + trigger_channel, + source_id, + source_cancel_handle.clone(), + source_cancel_at_sample.clone(), + ); + + // Schedule the source to start at a fixed delay from now for consistent latency + let start_at_sample = self.mixer.current_sample() + self.fixed_delay_samples; + + // Acquire voice manager lock BEFORE adding to mixer to prevent race conditions + // with concurrent triggers for the same sample (important for cut/monophonic mode) + let mut vm = self.voice_manager.write().unwrap(); + let to_stop = vm.add_voice(voice, sample.definition.retrigger()); + + // Schedule old voices to stop at the same time the new one starts (sample-accurate cut) + for cancel_at in to_stop { + cancel_at.store(start_at_sample, std::sync::atomic::Ordering::Relaxed); + } + + // Add the new source via channel (audio callback receives and adds it) + let active_source = crate::audio::mixer::ActiveSource { + id: source_id, + source: Box::new(channel_mapped), + track_mappings, + channel_mappings: Vec::new(), // Will be computed by mixer + cached_source_channel_count: precomputed.loaded.channel_count(), + is_finished: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + cancel_handle: source_cancel_handle.clone(), + start_at_sample: Some(start_at_sample), + cancel_at_sample: Some(source_cancel_at_sample.clone()), + }; + + // Send via channel - audio callback handles addition to mixer + if let Err(e) = self.source_tx.send(active_source) { + error!(error = %e, "Failed to send sample to mixer"); + } + drop(vm); + + debug!( + sample = sample_name, + velocity, volume, source_id, "Sample triggered" + ); + } + + /// Handles a Note Off event. + fn handle_note_off(&self, note: u8, channel: u8) { + // Find all samples that might have Note Off behavior + // and check their voices + let sample_behaviors: Vec<_> = self + .samples + .iter() + .map(|(name, s)| (name.clone(), s.definition.note_off())) + .collect(); + + for (name, behavior) in sample_behaviors { + if behavior == NoteOffBehavior::PlayToCompletion { + continue; + } + + let mut vm = self.voice_manager.write().unwrap(); + let to_stop = vm.handle_note_off(note, channel, behavior); + drop(vm); + + let stopped_count = to_stop.len(); + if !to_stop.is_empty() { + // Cancel voices via their handles (lock-free, no mixer write lock needed) + for handle in to_stop { + handle.cancel(); + } + debug!( + sample = name, + note, + channel, + stopped = stopped_count, + "Note Off handled" + ); + } + } + } + + /// Stops all sample playback. + pub fn stop_all(&self) { + let mut vm = self.voice_manager.write().unwrap(); + let to_stop = vm.clear(); + drop(vm); + + // Cancel all voices via their handles (lock-free) + let stopped_count = to_stop.len(); + for handle in to_stop { + handle.cancel(); + } + + if stopped_count > 0 { + info!(stopped = stopped_count, "All samples stopped"); + } + } + + /// Returns the number of active voices. + pub fn active_voice_count(&self) -> usize { + self.voice_manager.read().unwrap().active_count() + } + + /// Returns the total memory used by loaded samples. + pub fn memory_usage(&self) -> usize { + self.loader.total_memory_usage() + } +} + +impl std::fmt::Debug for SampleEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SampleEngine") + .field("samples", &self.samples.len()) + .field("triggers", &self.triggers.len()) + .field("active_voices", &self.active_voice_count()) + .field("memory_kb", &(self.memory_usage() / 1024)) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::mixer::AudioMixer; + + fn create_test_mixer_and_sender() -> (std::sync::Arc, crate::audio::SourceSender) { + let mixer = std::sync::Arc::new(AudioMixer::new(2, 44100)); + let (tx, _rx) = crossbeam_channel::unbounded(); + (mixer, tx) + } + + #[test] + fn test_engine_creation() { + let (mixer, source_tx) = create_test_mixer_and_sender(); + let engine = SampleEngine::new(mixer, source_tx, 32, 256); + + assert_eq!(engine.active_voice_count(), 0); + assert_eq!(engine.samples.len(), 0); + assert_eq!(engine.triggers.len(), 0); + } + + #[test] + fn test_velocity_extraction() { + let (mixer, source_tx) = create_test_mixer_and_sender(); + let engine = SampleEngine::new(mixer, source_tx, 32, 256); + + // Note On + let note_on = LiveEvent::Midi { + channel: 0.into(), + message: MidiMessage::NoteOn { + key: 60.into(), + vel: 100.into(), + }, + }; + assert_eq!(engine.extract_velocity(¬e_on), 100); + + // CC + let cc = LiveEvent::Midi { + channel: 0.into(), + message: MidiMessage::Controller { + controller: 1.into(), + value: 64.into(), + }, + }; + assert_eq!(engine.extract_velocity(&cc), 64); + } + + #[test] + fn test_trigger_matching() { + let (mixer, source_tx) = create_test_mixer_and_sender(); + let engine = SampleEngine::new(mixer, source_tx, 32, 256); + + let trigger = LiveEvent::Midi { + channel: 9.into(), // Channel 10 (0-indexed) + message: MidiMessage::NoteOn { + key: 36.into(), + vel: 127.into(), + }, + }; + + // Same note, different velocity - should match + let event1 = LiveEvent::Midi { + channel: 9.into(), + message: MidiMessage::NoteOn { + key: 36.into(), + vel: 80.into(), + }, + }; + + // Different note - should not match + let event2 = LiveEvent::Midi { + channel: 9.into(), + message: MidiMessage::NoteOn { + key: 37.into(), + vel: 127.into(), + }, + }; + + // Different channel - should not match + let event3 = LiveEvent::Midi { + channel: 0.into(), + message: MidiMessage::NoteOn { + key: 36.into(), + vel: 127.into(), + }, + }; + + assert!(engine.matches_trigger(&event1, &trigger)); + assert!(!engine.matches_trigger(&event2, &trigger)); + assert!(!engine.matches_trigger(&event3, &trigger)); + } +} diff --git a/src/samples/loader.rs b/src/samples/loader.rs new file mode 100644 index 0000000..d195db3 --- /dev/null +++ b/src/samples/loader.rs @@ -0,0 +1,276 @@ +// Copyright (C) 2026 Michael Wilson +// +// 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, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// + +//! Sample loading and caching for triggered samples. +//! +//! Samples are loaded entirely into memory at startup for zero-latency playback. + +use std::collections::HashMap; +use std::error::Error; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tracing::{debug, info, warn}; + +use crate::audio::sample_source::{create_sample_source_from_file, MemorySampleSource}; +use crate::config::samples::SampleDefinition; + +/// Default buffer size for reading samples (in samples, not bytes). +const DEFAULT_BUFFER_SIZE: usize = 4096; + +/// A loaded sample that can be played back. +/// The sample data is stored in an Arc for efficient sharing between voices. +#[derive(Clone)] +pub struct LoadedSample { + /// The sample data as f32 samples (interleaved if multi-channel). + data: Arc>, + /// Number of channels in the sample. + channel_count: u16, + /// Sample rate of the audio data. + sample_rate: u32, +} + +impl LoadedSample { + /// Creates a new MemorySampleSource for playback with the given volume. + pub fn create_source(&self, volume: f32) -> MemorySampleSource { + MemorySampleSource::from_shared( + self.data.clone(), + self.channel_count, + self.sample_rate, + volume, + ) + } + + /// Returns the number of channels. + pub fn channel_count(&self) -> u16 { + self.channel_count + } + + /// Returns the memory size in bytes. + pub fn memory_size(&self) -> usize { + self.data.len() * std::mem::size_of::() + } +} + +/// Manages loading and caching of sample data. +pub struct SampleLoader { + /// Cache of loaded samples by file path. + cache: HashMap, + /// Target sample rate for transcoding (matches audio output). + target_sample_rate: u32, +} + +impl SampleLoader { + /// Creates a new sample loader. + pub fn new(target_sample_rate: u32) -> Self { + Self { + cache: HashMap::new(), + target_sample_rate, + } + } + + /// Loads a sample from a file into memory. + /// Returns a cached version if already loaded. + pub fn load(&mut self, path: &Path) -> Result> { + // Check cache first + if let Some(sample) = self.cache.get(path) { + debug!(path = ?path, "Using cached sample"); + return Ok(sample.clone()); + } + + info!(path = ?path, "Loading sample into memory"); + + // Create a sample source from the file + let mut source = create_sample_source_from_file(path, None, DEFAULT_BUFFER_SIZE)?; + let source_sample_rate = source.sample_rate(); + let channel_count = source.channel_count(); + + // Read all samples into memory + let mut samples = Vec::new(); + while let Some(sample) = source.next_sample()? { + samples.push(sample); + } + + // Transcode if sample rate doesn't match + let (final_samples, final_sample_rate) = if source_sample_rate != self.target_sample_rate { + info!( + source_rate = source_sample_rate, + target_rate = self.target_sample_rate, + "Transcoding sample" + ); + let transcoded = self.transcode_samples( + &samples, + channel_count, + source_sample_rate, + self.target_sample_rate, + )?; + (transcoded, self.target_sample_rate) + } else { + (samples, source_sample_rate) + }; + + // Calculate duration + let total_samples = final_samples.len(); + let samples_per_channel = total_samples as f64 / channel_count as f64; + let duration_secs = samples_per_channel / final_sample_rate as f64; + let duration = Duration::from_secs_f64(duration_secs); + + let loaded = LoadedSample { + data: Arc::new(final_samples), + channel_count, + sample_rate: final_sample_rate, + }; + + info!( + path = ?path, + channels = channel_count, + sample_rate = final_sample_rate, + duration_ms = duration.as_millis(), + memory_kb = loaded.memory_size() / 1024, + "Sample loaded" + ); + + // Cache it + self.cache.insert(path.to_path_buf(), loaded.clone()); + + Ok(loaded) + } + + /// Loads all samples referenced by a sample definition. + /// Returns a map of file path to loaded sample. + pub fn load_definition( + &mut self, + definition: &SampleDefinition, + base_path: &Path, + ) -> Result, Box> { + let mut loaded = HashMap::new(); + + for file in definition.all_files() { + let full_path = if Path::new(file).is_absolute() { + PathBuf::from(file) + } else { + base_path.join(file) + }; + + match self.load(&full_path) { + Ok(sample) => { + loaded.insert(full_path, sample); + } + Err(e) => { + warn!(path = ?full_path, error = ?e, "Failed to load sample"); + return Err(e); + } + } + } + + Ok(loaded) + } + + /// Returns the total memory used by cached samples. + pub fn total_memory_usage(&self) -> usize { + self.cache.values().map(|s| s.memory_size()).sum() + } + + /// Transcodes samples from one sample rate to another using linear interpolation. + /// For higher quality, the existing Rubato transcoder could be used, but linear + /// interpolation is simpler and often sufficient for drum hits and one-shots. + fn transcode_samples( + &self, + samples: &[f32], + channel_count: u16, + source_rate: u32, + target_rate: u32, + ) -> Result, Box> { + let ratio = target_rate as f64 / source_rate as f64; + let source_frames = samples.len() / channel_count as usize; + let target_frames = (source_frames as f64 * ratio).ceil() as usize; + let channels = channel_count as usize; + + let mut output = Vec::with_capacity(target_frames * channels); + + for target_frame in 0..target_frames { + let source_pos = target_frame as f64 / ratio; + let source_frame = source_pos.floor() as usize; + let frac = source_pos.fract() as f32; + + for channel in 0..channels { + let idx0 = source_frame * channels + channel; + let idx1 = (source_frame + 1) * channels + channel; + + let s0 = samples.get(idx0).copied().unwrap_or(0.0); + let s1 = samples.get(idx1).copied().unwrap_or(s0); + + // Linear interpolation + let interpolated = s0 + (s1 - s0) * frac; + output.push(interpolated); + } + } + + Ok(output) + } +} + +impl std::fmt::Debug for SampleLoader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SampleLoader") + .field("cached_samples", &self.cache.len()) + .field("target_sample_rate", &self.target_sample_rate) + .field("total_memory_kb", &(self.total_memory_usage() / 1024)) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transcode_samples() { + let loader = SampleLoader::new(48000); + + // Simple mono sine wave at 44100Hz + let source_rate = 44100; + let target_rate = 48000; + let source_samples: Vec = (0..4410) + .map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / source_rate as f32).sin()) + .collect(); + + let result = loader + .transcode_samples(&source_samples, 1, source_rate, target_rate) + .unwrap(); + + // Should have more samples at higher rate + let expected_len = (4410.0_f64 * 48000.0 / 44100.0).ceil() as usize; + assert_eq!(result.len(), expected_len); + } + + #[test] + fn test_transcode_stereo() { + let loader = SampleLoader::new(48000); + + // Stereo: L=1.0, R=-1.0 alternating + let source_samples = vec![1.0f32, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]; + + let result = loader + .transcode_samples(&source_samples, 2, 44100, 48000) + .unwrap(); + + // Check that channels are preserved + assert!(result.len() >= 8); + // First frame should be close to original + assert!((result[0] - 1.0).abs() < 0.1); + assert!((result[1] - (-1.0)).abs() < 0.1); + } +} diff --git a/src/samples/voice.rs b/src/samples/voice.rs new file mode 100644 index 0000000..1823191 --- /dev/null +++ b/src/samples/voice.rs @@ -0,0 +1,386 @@ +// Copyright (C) 2026 Michael Wilson +// +// 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, version 3. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +// + +//! Voice management for polyphonic sample playback. +//! +//! Handles voice allocation, stealing, and note-off behavior. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use tracing::{debug, warn}; + +use crate::config::samples::{NoteOffBehavior, RetriggerBehavior}; +use crate::playsync::CancelHandle; + +/// Global voice ID counter. +static NEXT_VOICE_ID: AtomicU64 = AtomicU64::new(1); + +/// Represents an active voice playing a sample. +pub struct Voice { + /// Unique ID for this voice. + id: u64, + /// The sample name being played. + sample_name: String, + /// The MIDI note that triggered this voice (for Note Off matching). + trigger_note: Option, + /// The MIDI channel that triggered this voice (for Note Off matching). + trigger_channel: Option, + /// When this voice started playing. + start_time: Instant, + /// The audio source ID in the mixer (used for testing/debugging). + #[allow(dead_code)] + mixer_source_id: u64, + /// Cancel handle for stopping this voice without lock contention. + cancel_handle: CancelHandle, + /// Scheduled sample at which this voice should stop (for sample-accurate cuts). + cancel_at_sample: std::sync::Arc, +} + +impl Voice { + /// Creates a new voice. + pub fn new( + sample_name: String, + trigger_note: Option, + trigger_channel: Option, + mixer_source_id: u64, + cancel_handle: CancelHandle, + cancel_at_sample: std::sync::Arc, + ) -> Self { + Self { + id: NEXT_VOICE_ID.fetch_add(1, Ordering::SeqCst), + sample_name, + trigger_note, + trigger_channel, + start_time: Instant::now(), + mixer_source_id, + cancel_handle, + cancel_at_sample, + } + } + + /// Checks if this voice matches a Note Off event. + pub fn matches_note_off(&self, note: u8, channel: u8) -> bool { + match (self.trigger_note, self.trigger_channel) { + (Some(n), Some(c)) => n == note && c == channel, + (Some(n), None) => n == note, + _ => false, + } + } + + /// Returns a clone of this voice's cancel handle. + pub fn cancel_handle(&self) -> CancelHandle { + self.cancel_handle.clone() + } + + /// Returns a clone of this voice's cancel_at_sample. + pub fn cancel_at_sample(&self) -> std::sync::Arc { + self.cancel_at_sample.clone() + } +} + +#[cfg(test)] +impl Voice { + /// Returns the voice ID (test only). + pub fn id(&self) -> u64 { + self.id + } + + /// Returns the sample name (test only). + pub fn sample_name(&self) -> &str { + &self.sample_name + } + + /// Returns the mixer source ID (test only). + pub fn mixer_source_id(&self) -> u64 { + self.mixer_source_id + } + + /// Returns when this voice started (test only). + pub fn start_time(&self) -> Instant { + self.start_time + } +} + +/// Manages active voices for sample playback. +pub struct VoiceManager { + /// Active voices. + voices: Vec, + /// Global maximum voices limit. + max_voices: u32, + /// Per-sample voice limits (sample_name -> max_voices). + sample_limits: HashMap, +} + +impl VoiceManager { + /// Creates a new voice manager. + pub fn new(max_voices: u32) -> Self { + Self { + voices: Vec::new(), + max_voices, + sample_limits: HashMap::new(), + } + } + + /// Sets the per-sample voice limit. + pub fn set_sample_limit(&mut self, sample_name: &str, limit: u32) { + self.sample_limits.insert(sample_name.to_string(), limit); + } + + /// Adds a new voice, potentially stealing old voices if limits are exceeded. + /// Returns the cancel_at_sample Arcs for any voices that should be stopped. + /// The caller can set these to schedule the stop at a specific sample time. + pub fn add_voice( + &mut self, + voice: Voice, + retrigger: RetriggerBehavior, + ) -> Vec> { + let mut voices_to_stop = Vec::new(); + + // Handle retrigger behavior + match retrigger { + RetriggerBehavior::Cut => { + // Stop all existing voices for this sample + for v in self.voices.iter() { + if v.sample_name == voice.sample_name { + voices_to_stop.push(v.cancel_at_sample()); + } + } + self.voices.retain(|v| v.sample_name != voice.sample_name); + } + RetriggerBehavior::Polyphonic => { + // Check per-sample limit + if let Some(&limit) = self.sample_limits.get(&voice.sample_name) { + let count = self + .voices + .iter() + .filter(|v| v.sample_name == voice.sample_name) + .count(); + if count >= limit as usize { + // Steal oldest voice for this sample + if let Some(oldest) = self + .voices + .iter() + .filter(|v| v.sample_name == voice.sample_name) + .min_by_key(|v| v.start_time) + { + voices_to_stop.push(oldest.cancel_at_sample()); + let oldest_id = oldest.id; + self.voices.retain(|v| v.id != oldest_id); + debug!( + sample = voice.sample_name, + limit, "Per-sample voice limit reached, stealing oldest" + ); + } + } + } + } + } + + // Check global limit + if self.voices.len() >= self.max_voices as usize { + // Steal oldest voice globally + if let Some(oldest) = self.voices.iter().min_by_key(|v| v.start_time) { + voices_to_stop.push(oldest.cancel_at_sample()); + let oldest_id = oldest.id; + self.voices.retain(|v| v.id != oldest_id); + warn!( + max_voices = self.max_voices, + "Global voice limit reached, stealing oldest" + ); + } + } + + self.voices.push(voice); + voices_to_stop + } + + /// Handles a Note Off event for the specified note and channel. + /// Returns the cancel handles for voices that should be stopped or faded. + pub fn handle_note_off( + &mut self, + note: u8, + channel: u8, + behavior: NoteOffBehavior, + ) -> Vec { + let mut to_stop = Vec::new(); + + match behavior { + NoteOffBehavior::PlayToCompletion => { + // Do nothing - let the sample play to completion + } + NoteOffBehavior::Stop | NoteOffBehavior::Fade => { + // Find and remove matching voices + // Note: Fade currently behaves like Stop (immediate stop, no fade-out) + for v in self.voices.iter() { + if v.matches_note_off(note, channel) { + to_stop.push(v.cancel_handle()); + } + } + self.voices.retain(|v| !v.matches_note_off(note, channel)); + } + } + + to_stop + } + + /// Returns the current number of active voices. + pub fn active_count(&self) -> usize { + self.voices.len() + } + + /// Clears all voices. + /// Returns the cancel handles for all voices that should be stopped. + pub fn clear(&mut self) -> Vec { + let handles: Vec = self.voices.iter().map(|v| v.cancel_handle()).collect(); + self.voices.clear(); + handles + } +} + +#[cfg(test)] +impl VoiceManager { + /// Removes voices by their mixer source IDs (test only). + pub fn remove_by_source_ids(&mut self, source_ids: &[u64]) { + self.voices + .retain(|v| !source_ids.contains(&v.mixer_source_id)); + } + + /// Returns all active voices (test only). + pub fn voices(&self) -> &[Voice] { + &self.voices + } +} + +impl std::fmt::Debug for VoiceManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VoiceManager") + .field("active_voices", &self.voices.len()) + .field("max_voices", &self.max_voices) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_voice(sample: &str, note: Option, channel: Option, id: u64) -> Voice { + Voice::new( + sample.to_string(), + note, + channel, + id, + CancelHandle::new(), + std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), + ) + } + + #[test] + fn test_voice_note_off_matching() { + let voice = make_voice("test", Some(60), Some(10), 1); + + assert!(voice.matches_note_off(60, 10)); + assert!(!voice.matches_note_off(61, 10)); + assert!(!voice.matches_note_off(60, 11)); + + // Voice without channel should match any channel + let voice2 = make_voice("test", Some(60), None, 2); + assert!(voice2.matches_note_off(60, 10)); + assert!(voice2.matches_note_off(60, 5)); + assert!(!voice2.matches_note_off(61, 10)); + } + + #[test] + fn test_voice_manager_cut_retrigger() { + let mut manager = VoiceManager::new(32); + + let voice1 = make_voice("kick", Some(36), Some(10), 1); + let stopped = manager.add_voice(voice1, RetriggerBehavior::Cut); + assert!(stopped.is_empty()); + assert_eq!(manager.active_count(), 1); + + // Add another voice for the same sample - should cut the previous + let voice2 = make_voice("kick", Some(36), Some(10), 2); + let stopped = manager.add_voice(voice2, RetriggerBehavior::Cut); + assert_eq!(stopped.len(), 1); // One voice should be stopped + assert_eq!(manager.active_count(), 1); + } + + #[test] + fn test_voice_manager_polyphonic() { + let mut manager = VoiceManager::new(32); + manager.set_sample_limit("snare", 4); + + // Add 4 voices - should all be allowed + for i in 1..=4 { + let voice = make_voice("snare", Some(38), Some(10), i); + let stopped = manager.add_voice(voice, RetriggerBehavior::Polyphonic); + assert!(stopped.is_empty()); + } + assert_eq!(manager.active_count(), 4); + + // Add 5th voice - should steal oldest + let voice5 = make_voice("snare", Some(38), Some(10), 5); + let stopped = manager.add_voice(voice5, RetriggerBehavior::Polyphonic); + assert_eq!(stopped.len(), 1); // Voice 1 was oldest + assert_eq!(manager.active_count(), 4); + } + + #[test] + fn test_voice_manager_global_limit() { + let mut manager = VoiceManager::new(3); + + for i in 1..=3 { + let voice = make_voice(&format!("sample{}", i), Some(36), Some(10), i); + let stopped = manager.add_voice(voice, RetriggerBehavior::Polyphonic); + assert!(stopped.is_empty()); + } + + // Add 4th voice - should steal oldest globally + let voice4 = make_voice("sample4", Some(36), Some(10), 4); + let stopped = manager.add_voice(voice4, RetriggerBehavior::Polyphonic); + assert_eq!(stopped.len(), 1); + assert_eq!(manager.active_count(), 3); + } + + #[test] + fn test_note_off_stop() { + let mut manager = VoiceManager::new(32); + + let voice1 = make_voice("kick", Some(36), Some(10), 1); + let voice2 = make_voice("snare", Some(38), Some(10), 2); + manager.add_voice(voice1, RetriggerBehavior::Polyphonic); + manager.add_voice(voice2, RetriggerBehavior::Polyphonic); + + // Note Off for kick should stop only the kick + let stopped = manager.handle_note_off(36, 10, NoteOffBehavior::Stop); + assert_eq!(stopped.len(), 1); + assert_eq!(manager.active_count(), 1); + } + + #[test] + fn test_note_off_play_to_completion() { + let mut manager = VoiceManager::new(32); + + let voice = make_voice("kick", Some(36), Some(10), 1); + manager.add_voice(voice, RetriggerBehavior::Polyphonic); + + // Note Off with PlayToCompletion should not stop anything + let stopped = manager.handle_note_off(36, 10, NoteOffBehavior::PlayToCompletion); + assert!(stopped.is_empty()); + assert_eq!(manager.active_count(), 1); + } +} diff --git a/src/songs.rs b/src/songs.rs index 3814046..5dfe1e8 100644 --- a/src/songs.rs +++ b/src/songs.rs @@ -93,6 +93,8 @@ impl DslLightingShow { pub struct Song { /// The name of the song. name: String, + /// The base path of the song (directory containing the song config). + base_path: PathBuf, /// The MIDI event to play when the song is selected in a playlist. midi_event: Option>, /// The MIDI playback configuration. @@ -111,6 +113,8 @@ pub struct Song { duration: Duration, /// The individual audio tracks. tracks: Vec, + /// Per-song samples configuration. + samples_config: config::SamplesConfig, } /// A simple sample for songs. Boils down to i32 or f32, which we can be reasonably assured that @@ -174,6 +178,7 @@ impl Song { Ok(Song { name: config.name().to_string(), + base_path: start_path.to_path_buf(), midi_event: config.midi_event()?, midi_playback, light_shows, @@ -183,6 +188,7 @@ impl Song { sample_format: sample_format.unwrap_or(SampleFormat::Int), duration: max_duration, tracks, + samples_config: config.samples_config(), }) } @@ -246,10 +252,12 @@ impl Song { } let song = Self { name, + base_path: song_directory.clone(), midi_playback, light_shows, dsl_lighting_shows: Vec::new(), // No DSL lighting in auto-discovered songs tracks, + samples_config: config::SamplesConfig::default(), ..Default::default() }; Ok(song) @@ -292,6 +300,8 @@ impl Song { light_shows, None, // No DSL lighting shows in config - they're handled separately tracks, + std::collections::HashMap::new(), // No sample overrides when creating from Song + Vec::new(), // No sample trigger overrides ) } @@ -300,6 +310,16 @@ impl Song { &self.name } + /// Gets the base path of the song (directory containing the song config). + pub fn base_path(&self) -> &Path { + &self.base_path + } + + /// Gets the per-song samples configuration. + pub fn samples_config(&self) -> &config::SamplesConfig { + &self.samples_config + } + /// Gets the MIDI event. pub fn midi_event(&self) -> Option> { self.midi_event @@ -495,6 +515,7 @@ impl Default for Song { fn default() -> Self { Self { name: Default::default(), + base_path: PathBuf::new(), midi_event: Default::default(), midi_playback: Default::default(), light_shows: Vec::new(), @@ -504,6 +525,7 @@ impl Default for Song { sample_format: SampleFormat::Int, duration: Default::default(), tracks: Default::default(), + samples_config: config::SamplesConfig::default(), } } } diff --git a/src/testutil.rs b/src/testutil.rs index 759297e..818ecbf 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -276,6 +276,8 @@ songs: "songs" crate::config::LightingShow::new("lighting/outro.light".to_string()), ]), vec![], + std::collections::HashMap::new(), + Vec::new(), ); // Verify song has lighting shows @@ -500,6 +502,8 @@ songs: "songs" "show.light".to_string(), )]), vec![], + std::collections::HashMap::new(), + Vec::new(), ); // Verify the song has lighting shows