From 430dce674dc3e1b9869676b4a77f7cc550730e38 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 31 Jan 2026 13:53:02 -0500 Subject: [PATCH] Add in sample triggering functionality. It's now possible to trigger samples from mtrack. This is useful if using in conjunction with drum triggers. It should allow for easy routing of triggers into your existing audio interface. This is coupled with a buffer size fix that propagates the buffer down to the audio interface, as well as elimination of the ring buffer as we're now more oriented towards real time sampling. --- CHANGELOG.md | 39 +- Cargo.lock | 393 ++++++++++-------- Cargo.toml | 2 +- README.md | 181 ++++++++- examples/mtrack.yaml | 74 ++++ src/audio.rs | 15 + src/audio/cpal.rs | 306 +++++--------- src/audio/mixer.rs | 79 +++- src/audio/sample_source.rs | 5 +- src/audio/sample_source/memory.rs | 64 ++- src/config.rs | 8 + src/config/controller.rs | 11 + src/config/midi.rs | 46 ++- src/config/player.rs | 56 ++- src/config/samples.rs | 580 +++++++++++++++++++++++++++ src/config/song.rs | 24 +- src/controller/grpc.rs | 11 +- src/controller/midi.rs | 3 + src/controller/osc.rs | 6 + src/dmx/engine.rs | 4 + src/lib.rs | 1 + src/main.rs | 1 + src/player.rs | 97 ++++- src/proto/player/v1/player.proto | 9 + src/samples.rs | 33 ++ src/samples/engine.rs | 646 ++++++++++++++++++++++++++++++ src/samples/loader.rs | 276 +++++++++++++ src/samples/voice.rs | 386 ++++++++++++++++++ src/songs.rs | 22 + src/testutil.rs | 4 + 30 files changed, 2980 insertions(+), 402 deletions(-) create mode 100644 src/config/samples.rs create mode 100644 src/samples.rs create mode 100644 src/samples/engine.rs create mode 100644 src/samples/loader.rs create mode 100644 src/samples/voice.rs 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