diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 578d8ff..089c667 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,25 +25,24 @@ jobs: matrix: include: - { name: ubuntu-22.04, os: ubuntu-22.04, cross-target: '' } - - { name: macos-12-x86_64, os: macos-12, cross-target: '' } - - { name: macos-12-aarch64, os: macos-12, cross-target: aarch64-apple-darwin } - { name: windows, os: windows-latest, cross-target: '' } + - { name: macos-15-aarch64, os: macos-15, cross-target: '' } + - { name: macos-15-x86_64, os: macos-15, cross-target: x86_64-apple-darwin } name: Build binary runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Fetch all git history run: git fetch --force --prune --tags --unshallow - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: | - ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ matrix.name }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: build-${{ matrix.name }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -92,7 +91,7 @@ jobs: universal-binary: name: Build a universal macOS binary - runs-on: macos-12 + runs-on: macos-15 needs: package steps: - uses: actions/checkout@v4 @@ -101,8 +100,8 @@ jobs: - name: Determine the previously build archive names run: | - echo "X86_64_ARCHIVE_NAME=clap-validator-$(git describe --always)-macos-12-x86_64" >> "$GITHUB_ENV" - echo "AARCH64_ARCHIVE_NAME=clap-validator-$(git describe --always)-macos-12-aarch64" >> "$GITHUB_ENV" + echo "X86_64_ARCHIVE_NAME=clap-validator-$(git describe --always)-macos-15-x86_64" >> "$GITHUB_ENV" + echo "AARCH64_ARCHIVE_NAME=clap-validator-$(git describe --always)-macos-15-aarch64" >> "$GITHUB_ENV" - name: Determine archive name for the universal binary run: | diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b3cb484 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,41 @@ +name: Test Validate + +on: + push: + pull_request: + branches: + - master + +defaults: + run: + shell: bash + +jobs: + check: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + name: Check on ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: check-${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run clippy + run: cargo clippy --all -- -D warnings + + - name: Run format check + run: cargo fmt --all -- --check + + - name: Run tests + run: cargo test --all -- --test-threads 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b83d222..aaf3de7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target/ +rustc-ice-*.txt \ No newline at end of file diff --git a/.rustfmt.toml b/.rustfmt.toml deleted file mode 100644 index a5a6806..0000000 --- a/.rustfmt.toml +++ /dev/null @@ -1 +0,0 @@ -format_strings = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cda82..960fcc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.4.1] - 2026-07-19 + +### Added +- Added an experimental fuzzing harness that runs plugins for an extended period of time to discover potential issues. +- Add parameter rescan checks to `state-reproducibility-*` tests. +- Add `render` extension support, call `render::set` while fuzzing. +- Add overlapping & wildcard event checks. +- Add out-of-bounds write checks for output/inplace buffers. ### Changed +- Bump MSRV to 1.95.0. +- Remove `features-standard` test (too strict). + +### Fixed +- Allow calling host methods inside `clap_plugin::init`. +- Fix `clap_plugin::activate` cycle detection logic (thanks @edwloef!) + +## [0.4.0] - 2026-03-28 + +### Added +- New tests: + - `features-standard` + - `layout-audio-ports-activation` + - `layout-audio-ports-config` + - `layout-configurable-audio-ports` + - `process-audio-basic-in-place` + - `process-audio-double-in-place` + - `process-audio-double-out-of-place` + - `process-audio-denormals` + - `process-sleep-constant-mask` + - `process-sleep-process-status` + - `process-varying-block-sizes` + - `process-varying-sample-rates` + - `process-random-block-sizes` + - `process-reset-reactivate` + - `param-fuzz-bounds` + - `param-fuzz-sample-accurate` + - `param-fuzz-modulation` + - `param-set-events` + - `param-set-no-cookies` + - `param-default-values` + - `state-invalid-random` + - `state-reproducibility-binary` (other reproducibility tests have been relaxed to not check for exact binary state matches) + - `transport-null` + - `transport-fuzz` + - `transport-fuzz-sample-accurate` + +- Extra checks: + - `ambisonic`/`surround` checks when querying audio port info. + - Thread/state/extension checks for host callback functions. + - More lenient param reproducibility checks that allow for some imprecision when comparing parameter values. + - Object validity checks, now passing an invalid object pointer to a callback will catch it and report it as an error instead of causing undefined behavior in the validator. This has some performance impact but it is negligible. + - Implemented more host-side extensions and specific checks for each host-side extension callback. + - Implemented infinite restart loop check in activate (similar to the infinite `request_callback` loop check for `on_main_thread`). + - Check if `clap_audio_buffer` data is not modified by the plugin within the `clap_plugin::process` call. + +### Changed + +- Update `clap-sys` to latest (CLAP 1.2.2) - Having both the `CLAP_PARAM_IS_READONLY` flag and any of the `CLAP_PARAM_IS_AUTOMATABLE` or `CLAP_PARAM_IS_MODULATABLE` flags set now results in an error. @@ -17,6 +73,20 @@ Versioning](https://semver.org/spec/v2.0.0.html). parameters marked as automatable could be changed. Now parameters marked as hidden or readonly are ignored instead, as non-automatable parameters can still be changed as the result of live user input. +- When doing out-of-process validation, the validator now also checks for timeouts in addition to crashes. +If a plugin takes too long to respond during validation, the validator will kill the process and report a timeout error. The timeout duration is currently set to 45 seconds. +- Pretty printing of validation/list/scan results in non-JSON output modes has been improved. +- Changed log formatting to be prettier, plugin log messages are propagated to the validator's output via the `log` extension. +- Added `clap-validator.toml` config files for easier per-project test enabling/disabling. +- When running validation in-process, it is possible to turn on tracing, which emits a detailed Chrome-tracing compatible .json trace file that can be loaded into Chrome's tracing viewer or other compatible tools. This is especially useful for debugging test failures and crashes. + +- Removed tests: + - `state-reproducibility-flush` - replaced by `param-set-events`. + - `state-reproducibility-null-cookies` - replaced by `param-set-no-cookies`. + +### Fixed + - Wrong state transition check on plugin instance destroy. + - Output note events were queried as inputs. ## [0.3.2] - 2023-03-25 diff --git a/Cargo.lock b/Cargo.lock index c3aa7d3..414ad4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,51 +1,27 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - -[[package]] -name = "aho-corasick" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" -dependencies = [ - "memchr", -] - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +version = 4 [[package]] name = "anstream" -version = "0.3.2" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", - "is-terminal", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.1" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -62,30 +38,34 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] name = "anstyle-wincon" -version = "1.0.1" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.72" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "autocfg" -version = "1.1.0" +name = "basic-toml" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] [[package]] name = "bitflags" @@ -95,124 +75,135 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.3" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] -name = "bumpalo" -version = "3.13.0" +name = "cfg-if" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "cc" -version = "1.0.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c6b2562119bf28c3439f7f02db99faf0aa1a8cdfe5772a2ee155d32227239f0" +name = "clack-common" +version = "0.1.0" +source = "git+https://github.com/prokopyl/clack?rev=3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6#3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6" dependencies = [ - "libc", + "bitflags 2.10.0", + "clap-sys", ] [[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +name = "clack-effect" +version = "0.1.0" +dependencies = [ + "clack-extensions", + "clack-plugin", +] [[package]] -name = "chrono" -version = "0.4.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +name = "clack-extensions" +version = "0.1.0" +source = "git+https://github.com/prokopyl/clack?rev=3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6#3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6" dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "time 0.1.45", - "wasm-bindgen", - "winapi", + "bitflags 2.10.0", + "clack-common", + "clack-plugin", + "clap-sys", +] + +[[package]] +name = "clack-plugin" +version = "0.1.0" +source = "git+https://github.com/prokopyl/clack?rev=3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6#3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6" +dependencies = [ + "clack-common", + "clap-sys", +] + +[[package]] +name = "clack-synth" +version = "0.1.0" +dependencies = [ + "clack-extensions", + "clack-plugin", ] [[package]] name = "clap" -version = "4.3.19" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd304a20bff958a57f04c4e96a2e7594cc4490a0e809cbd48bb6437edaa452d" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap-sys" -version = "0.3.0" -source = "git+https://github.com/robbert-vdh/clap-sys.git?rev=04779b57663f6f3f710cb813bde0e499a6515d17#04779b57663f6f3f710cb813bde0e499a6515d17" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154" [[package]] name = "clap-validator" -version = "0.3.2" +version = "0.4.1" dependencies = [ "anyhow", - "chrono", + "basic-toml", "clap", "clap-sys", - "colored", "core-foundation", - "crossbeam", + "crossbeam-utils", + "either", "libloading", "log", - "log-panics", - "midi-consts", - "parking_lot", "rand", - "rand_pcg", - "rayon", - "regex", + "regex-lite", + "rustc-hash", "serde", "serde_json", - "serde_with", - "simplelog", "strum", "strum_macros", "tempfile", "textwrap", + "time", + "wait-timeout", "walkdir", + "yansi", ] [[package]] name = "clap_builder" -version = "4.3.19" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c6a3f08f1fe5662a35cfe393aec09c4df95f60ee93b7556505260f75eee9e1" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", - "terminal_size 0.2.6", + "terminal_size", ] [[package]] name = "clap_derive" -version = "4.3.12" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.28", + "syn", ] [[package]] name = "clap_lex" -version = "0.5.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -220,22 +211,11 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" -[[package]] -name = "colored" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" -dependencies = [ - "is-terminal", - "lazy_static", - "windows-sys", -] - [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -243,111 +223,15 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" - -[[package]] -name = "crossbeam" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" -dependencies = [ - "cfg-if", - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.8" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" -dependencies = [ - "cfg-if", - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" -dependencies = [ - "autocfg", - "cfg-if", - "crossbeam-utils", - "memoffset", - "scopeguard", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" -dependencies = [ - "cfg-if", - "crossbeam-utils", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crossbeam-utils" -version = "0.8.16" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "darling" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 1.0.109", -] - -[[package]] -name = "darling_macro" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" -dependencies = [ - "darling_core", - "quote", - "syn 1.0.109", -] +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "deranged" @@ -356,33 +240,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] name = "either" -version = "1.9.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "errno" -version = "0.3.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" -dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "errno-dragonfly" -version = "0.1.2" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "cc", "libc", + "windows-sys 0.61.2", ] [[package]] @@ -392,84 +272,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" [[package]] -name = "fnv" -version = "1.0.7" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "getrandom" -version = "0.2.10" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "heck" -version = "0.4.1" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "hermit-abi" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] -name = "iana-time-zone" -version = "0.1.57" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows", -] +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "ident_case" -version = "1.0.1" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "io-lifetimes" -version = "1.0.11" +name = "indexmap" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ - "hermit-abi", - "libc", - "windows-sys", + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] -name = "is-terminal" -version = "0.4.9" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" -dependencies = [ - "hermit-abi", - "rustix 0.38.6", - "windows-sys", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" @@ -478,42 +342,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] -name = "js-sys" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.147" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" -version = "0.7.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "winapi", + "windows-link", ] -[[package]] -name = "linux-raw-sys" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - [[package]] name = "linux-raw-sys" version = "0.4.5" @@ -521,29 +370,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" [[package]] -name = "lock_api" -version = "0.4.10" +name = "linux-raw-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" -dependencies = [ - "autocfg", - "scopeguard", -] +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" - -[[package]] -name = "log-panics" -version = "2.1.0" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dd8546191c1850ecf67d22f5ff00a935b890d0e84713159a55495cc2ac5f" -dependencies = [ - "log", -] +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" @@ -551,21 +387,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" -[[package]] -name = "memoffset" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" -dependencies = [ - "autocfg", -] - -[[package]] -name = "midi-consts" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" - [[package]] name = "num-conv" version = "0.1.0" @@ -573,61 +394,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] -name = "num-traits" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.16.0" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "num_threads" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" -dependencies = [ - "libc", -] - -[[package]] -name = "once_cell" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "powerfmt" @@ -636,89 +406,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "ppv-lite86" -version = "0.2.17" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" -version = "1.0.66" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.32" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand_core" -version = "0.6.4" +name = "rand" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ "getrandom", -] - -[[package]] -name = "rand_pcg" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" -dependencies = [ "rand_core", ] [[package]] -name = "rayon" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.11.0" +name = "rand_core" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-utils", - "num_cpus", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "redox_syscall" @@ -730,67 +465,43 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.9.1" +name = "regex-lite" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" [[package]] -name = "regex-automata" -version = "0.3.4" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "0.37.23" +version = "0.38.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06" +checksum = "1ee020b1716f0a80e2ace9b03441a749e402e86712f15f16fe8a8f75afac732f" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "errno", - "io-lifetimes", "libc", - "linux-raw-sys 0.3.8", - "windows-sys", + "linux-raw-sys 0.4.5", + "windows-sys 0.48.0", ] [[package]] name = "rustix" -version = "0.38.6" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee020b1716f0a80e2ace9b03441a749e402e86712f15f16fe8a8f75afac732f" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.3.3", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.4.5", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] -[[package]] -name = "rustversion" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" - [[package]] name = "ryu" version = "1.0.15" @@ -807,128 +518,89 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.193" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.193" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.28", -] - -[[package]] -name = "serde_json" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" -dependencies = [ - "serde", - "serde_with_macros", + "serde_derive", ] [[package]] -name = "serde_with_macros" -version = "1.5.2" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "darling", "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] -name = "simplelog" -version = "0.12.1" +name = "serde_json" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acee08041c5de3d5048c8b3f6f13fafb3026b24ba43c6a695a0c76179b844369" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "log", - "termcolor", - "time 0.3.36", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", ] -[[package]] -name = "smallvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" - [[package]] name = "smawk" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.24.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" [[package]] name = "strum_macros" -version = "0.24.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 1.0.109", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "syn", ] [[package]] name = "syn" -version = "2.0.28" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04361975b3f5e348b2189d8dc55bc942f278b2d482a6a0365de5bdd62d351567" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -945,61 +617,31 @@ dependencies = [ "fastrand", "redox_syscall", "rustix 0.38.6", - "windows-sys", -] - -[[package]] -name = "termcolor" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" -dependencies = [ - "winapi-util", + "windows-sys 0.48.0", ] [[package]] name = "terminal_size" -version = "0.1.17" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "terminal_size" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" -dependencies = [ - "rustix 0.37.23", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.60.2", ] [[package]] name = "textwrap" -version = "0.15.2" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", - "terminal_size 0.1.17", + "terminal_size", "unicode-linebreak", "unicode-width", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - [[package]] name = "time" version = "0.3.36" @@ -1007,10 +649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", - "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde", "time-core", @@ -1047,15 +686,30 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-width" -version = "0.1.10" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] [[package]] name = "walkdir" @@ -1068,70 +722,56 @@ dependencies = [ ] [[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.87" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen 0.46.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.87" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.28", - "wasm-bindgen-shared", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.87" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "leb128fmt", + "wasmparser", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.87" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.28", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.87" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] name = "winapi" @@ -1165,21 +805,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.1", ] [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -1188,13 +843,30 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1203,38 +875,186 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index 45ce2c7..d9968ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,44 +1,49 @@ +[workspace] +members = [ + ".", + "tests/clack-synth", + "tests/clack-effect", +] + [package] name = "clap-validator" -version = "0.3.2" -edition = "2021" +version = "0.4.1" +edition = "2024" license = "MIT" -rust-version = "1.64.0" # MSRV - +rust-version = "1.95.0" # MSRV description = "A validator and automatic test suite for CLAP plugins" readme = "README.md" repository = "https://github.com/free-audio/clap-validator" +authors = [ + "Robbert van der Helm ", + "Quant1um " +] [dependencies] anyhow = "1.0.58" -chrono = { version = "0.4.23", features = ["serde"] } -# All the claps! -clap = { version = "4.1.8", features = ["derive", "wrap_help"] } -# For CLAP 1.1.8 support -clap-sys = { git = "https://github.com/robbert-vdh/clap-sys.git", rev = "04779b57663f6f3f710cb813bde0e499a6515d17" } -colored = "2.0.0" -crossbeam = "0.8.1" -libloading = "0.7.3" +basic-toml = "0.1.10" +either = "1.9.0" +clap = { version = "4.5.58", features = ["derive", "wrap_help", "env"] } +clap-sys = "0.5.0" +crossbeam-utils = "0.8.21" +libloading = "0.9.0" log = "0.4" -log-panics = "2.0" -midi-consts = "0.1.0" -parking_lot = "0.12.1" -rand = "0.8.5" -rand_pcg = "0.3.1" -rayon = "1.6.1" -regex = "1.6" +rand = { version = "0.10.0", default-features = false, features = ["std"] } +regex-lite = "0.1.8" +rustc-hash = "2.1.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_with = "1.12.0" -simplelog = "0.12" -strum = "0.24.1" -strum_macros = "0.24.1" +strum = "0.28.0" +strum_macros = "0.28.0" tempfile = "3.3" -textwrap = { version = "0.15.0", features = ["terminal_size"] } +textwrap = { version = "0.16.2", features = ["terminal_size"] } +time = { version = "0.3", features = ["serde"] } walkdir = "2.3" +wait-timeout = "0.2.1" +yansi = "1.0.1" [target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.9.3" +core-foundation = "0.10.1" [profile.profiling] inherits = "release" diff --git a/README.md b/README.md index fc97e0f..8dba3fa 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,18 @@ # clap-validator -[![Automated builds](https://github.com/free-audio/clap-validator/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/free-audio/clap-validator/actions/workflows/build.yml?query=branch%3Amaster) +[![Automated builds](https://github.com/blepfx/clap-validator/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/blepfx/clap-validator/actions/workflows/build.yml?query=branch%3Amaster) -A validator and automatic test suite for [CLAP](https://github.com/free-audio/clap) plugins. Clap-validator can automatically test one or more plugins for common bugs and incorrect behavior. +A validator and automatic test suite for [CLAP](https://github.com/free-audio/clap) plugins. Clap-validator can automatically test one or more plugins for common bugs and incorrect behavior. See [CHANGELOG](./CHANGELOG.md) for a detailed list of changes and additions in each version. ## Download -Prebuilt binaries can be found on the [releases -page](https://github.com/free-audio/clap-validator/releases). Development builds -can be found -[here](https://nightly.link/free-audio/clap-validator/workflows/build/master). +Development builds can be found +[here](https://nightly.link/blepfx/clap-validator/workflows/build/master). The macOS builds are unsigned and may require Gatekeeper to be disabled or the quarantine bit to be removed ([instructions](https://disable-gatekeeper.github.io/)). -### Usage +## Usage Simply pass the path to one or more `.clap` plugins to `clap-validator validate` to run the validator on those plugins. The `--only-failed` option can be used to @@ -27,7 +25,36 @@ clap-validator validate /path/to/the/plugin.clap --only-failed clap-validator validate --help ``` -### Debugging +### Filtering + +By default, all tests are run during validation, including pedantic ones. You can use the `--include` option to specify a regex of tests to run, and `--exclude` to specify a regex of tests to skip. Another option is to create a configuration file named `clap-validator.toml` in the current working directory or any of its parent directories. In this file, you can specify which tests to enable or disable. An example configuration file looks like this: + +```toml +# clap-validator.toml +[test] +state-reproducibility-binary = false +``` + +### Fuzzing + +> [!WARNING] +> Fuzzing is experimental and can contain bugs that can cause false positives even if your plugin is perfectly fine. + +clap-validator comes with a built-in multi-process fuzzer that can run the plugin through a series of random parameter changes, note on/off events, and transport changes while checking for crashes, hangs, and spec-compliance issues. Use `clap-validator fuzz` to run the fuzzer (4 parallel runners for 10.5 minutes): + +```shell +clap-validator fuzz -j4 -d10m30s /path/to/the/plugin.clap +``` + +If the fuzzer finds a crash, it will print a crash info and a seed that can be used to reproduce the issue. To reproduce a crash, use the `--reproduce` option: + +```shell +clap-validator fuzz --reproduce /path/to/the/plugin.clap +``` + +This will run a single fuzzer "chunk" with that seed in-process, repeating the same sequence of operations that led to the issue. You can attach a debugger, or enable tracing while using `--reproduce`. + +## Debugging clap-validator runs tests in separate processes by default so plugin crashes can be treated as such instead of taking down the validator. If you want to attach a @@ -36,13 +63,28 @@ validator to run the that test in the current process. Use `clap-validator list to list all available tests. ```shell -clap-validator validate --in-process --test-filter /path/to/the/plugin.clap +clap-validator validate --in-process --include /path/to/the/plugin.clap ``` +### Tracing + +> [!WARNING] +> Tracing can cause some overhead AND because it emits events without any buffering (to avoid losing events in case of a crash), it can mask a plugin crash or change the timing of events enough to make some issues not reproducible. Use with caution. + +clap-validator can generate traces of plugin/host call execution during the in-process tests that could be used to diagnose issues or understand plugin behavior. To enable tracing, pass the `--trace` option to `clap-validator validate`. The generated trace files can be opened in [Perfetto](https://perfetto.dev/). + ## Building After installing [Rust](https://rustup.rs/), you can compile and run clap-validator as follows: ```shell -cargo run --release -- validate /path/to/the/plugin.clap +cargo build --release # build the binary +./target/release/clap-validator validate /path/to/the/plugin.clap # and run it ``` + +or + +```shell +cargo run --release -- validate /path/to/the/plugin.clap # build & run +``` + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f0a4196 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +format_strings = true +comment_width = 120 +max_width = 120 +imports_granularity = "Module" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b0f76d0 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,90 @@ +mod config; +mod log; +mod panic; +mod print; +pub mod sandbox; +pub mod tracing; + +pub use config::*; +pub use log::*; +pub use panic::*; +pub use print::*; + +/// A temporary directory used by the validator. This is cleared when launching the validator. +pub fn validator_temp_dir() -> std::path::PathBuf { + /// [`std::env::temp_dir`], but taking `XDG_RUNTIME_DIR` on Linux into account. + fn temp_dir() -> std::path::PathBuf { + #[cfg(all(unix, not(target_os = "macos")))] + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR").map(std::path::PathBuf::from) + && dir.is_dir() + { + return dir; + } + + std::env::temp_dir() + } + + temp_dir().join("clap-validator") +} + +impl IteratorExt for T where T: Iterator {} +pub trait IteratorExt: Iterator { + fn parallel_fork_join( + self, + workers: Option, + fork: impl Fn(Self::Item) -> R + Send + Sync, + mut join: impl FnMut(R), + ) where + Self: Sized + Send, + Self::Item: Send, + { + use std::sync::Mutex; + + let workers = match workers { + Some(n) => n, + None => std::thread::available_parallelism().map_or(1, |n| n.get()), + }; + + if workers <= 1 { + return self.map(fork).for_each(join); + } + + let inputs = Mutex::new(self.fuse()); + let (output_tx, output_rx) = std::sync::mpsc::channel(); + + std::thread::scope(|scope| { + for _ in 0..workers { + let map = ⋔ + let inputs = &inputs; + let output_tx = output_tx.clone(); + scope.spawn(move || { + loop { + let item = inputs.lock().unwrap().next(); + let Some(item) = item else { break }; + output_tx.send(map(item)).unwrap(); + } + }); + } + + drop(output_tx); + while let Ok(item) = output_rx.recv() { + join(item); + } + }); + } + + /// Map this iterator in parallel using the given number of worker threads. Unordered. + fn parallel_map( + self, + workers: Option, + f: impl Fn(Self::Item) -> R + Send + Sync, + ) -> impl Iterator + where + Self: Sized + Send, + Self::Item: Send, + { + let mut vec = Vec::with_capacity(self.size_hint().0); + self.parallel_fork_join(workers, f, |item| vec.push(item)); + vec.into_iter() + } +} diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 0000000..ac844a5 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,41 @@ +use anyhow::{Context, Result}; +use std::collections::HashMap; + +#[derive(Debug, Default, serde::Deserialize)] +pub struct Config { + pub test: HashMap, +} + +impl Config { + pub fn from_current() -> Result { + // use env var if set + if let Ok(path) = std::env::var("CLAP_VALIDATOR_CONFIG") { + return Self::from_file(&path).context(path); + } + + // scan up and look for clap-validator.toml + let mut current_dir = std::env::current_dir()?.canonicalize()?; + loop { + let config_path = current_dir.join("clap-validator.toml"); + if config_path.exists() { + return Self::from_file(&config_path); + } + + if !current_dir.pop() { + break; + } + } + + Ok(Self::default()) + } + + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path).with_context(|| path.display().to_string())?; + basic_toml::from_str(&contents).with_context(|| path.display().to_string()) + } + + pub fn is_test_enabled(&self, test_name: &str) -> bool { + self.test.get(test_name).copied().unwrap_or(true) + } +} diff --git a/src/cli/log.rs b/src/cli/log.rs new file mode 100644 index 0000000..69ff076 --- /dev/null +++ b/src/cli/log.rs @@ -0,0 +1,69 @@ +//! A tracing layer that logs events to standard output in a compact human readable format. + +use crate::cli::tracing::{event, record}; +use std::cell::RefCell; +use std::fmt::Write; +use std::sync::OnceLock; +use std::time::SystemTime; +use yansi::Paint; + +/// The time of origin for the log timestamps. +/// Timestamps are logged as '{}ms' where the number of milliseconds is the duration since this timestamp. +pub fn timebase() -> SystemTime { + static TIMEBASE: OnceLock = OnceLock::new(); + *TIMEBASE.get_or_init(|| { + std::env::var("CLAP_VALIDATOR_TIMEBASE") + .ok() + .and_then(|s| s.parse::().ok()) + .map(|secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs_f64(secs)) + .unwrap_or_else(SystemTime::now) + }) +} + +pub struct CustomLogger; + +impl log::Log for CustomLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + + fn log(&self, log: &log::Record) { + thread_local! { + static BUFFER: RefCell = const { RefCell::new(String::new()) } + } + + let elapsed = SystemTime::now() + .duration_since(timebase()) + .unwrap_or_default() + .as_secs_f64() + * 1000.0; + + let prefix = match log.level() { + log::Level::Error => "ERROR".red().bold(), + log::Level::Warn => " WARN".yellow(), + log::Level::Info => " INFO".green(), + log::Level::Debug => "DEBUG".blue(), + log::Level::Trace => "TRACE".white(), + }; + + event( + log.args(), + record! { + level: log.level().to_string(), + target: log.target() + }, + ); + + BUFFER.with_borrow_mut(|buffer| { + buffer.clear(); + write!(buffer, "{:>5.0}{}", elapsed.dim(), "ms".dim()).ok(); + write!(buffer, " {} ", prefix).ok(); + write!(buffer, "{}", log.args()).ok(); + write!(buffer, " {}", log.target().dim().italic()).ok(); + writeln!(buffer).ok(); + eprint!("{}", buffer); + }); + } + + fn flush(&self) {} +} diff --git a/src/cli/panic.rs b/src/cli/panic.rs new file mode 100644 index 0000000..6b2b1e1 --- /dev/null +++ b/src/cli/panic.rs @@ -0,0 +1,69 @@ +//! Panic handling utilities. +//! +//! When testing, validator should return an `Error` if the plugin is misbehaving, not panic. +//! Any panics (except for [`fail_test!`]) while testing are considered a bug in the validator itself, making them worth logging. + +pub fn install_panic_hook() { + #[track_caller] + fn hook(info: &std::panic::PanicHookInfo) { + let backtrace = std::backtrace::Backtrace::capture(); + let backtrace = if backtrace.status() == std::backtrace::BacktraceStatus::Disabled { + String::from(". Set RUST_BACKTRACE=1 for a backtrace.") + } else { + format!("\n{}", backtrace) + }; + + let thread = std::thread::current().name().unwrap_or("").to_owned(); + let message = panic_message(info.payload()); + + match info.location() { + Some(location) => { + log::error!( + target: "panic", "thread '{}' panicked at '{}': {}:{}{}", + thread, + message, + location.file(), + location.line(), + backtrace + ); + } + None => log::error!( + target: "panic", + "thread '{}' panicked at '{}'{:?}", + thread, + message, + backtrace + ), + } + } + + std::panic::set_hook(Box::new(hook)); +} + +pub fn panic_message(panic: &dyn std::any::Any) -> String { + if let Some(s) = panic.downcast_ref::<&'static str>() { + format!("{}. This is a bug in the validator", s) + } else if let Some(s) = panic.downcast_ref::() { + format!("{}. This is a bug in the validator", s) + } else if let Some(message) = panic.downcast_ref::() { + message.0.clone() + } else { + "A panic occurred. This is a bug in the validator".to_string() + } +} + +#[doc(hidden)] +pub struct TestFailure(pub String); + +/// Fails the current test with a panic, taking down the whole process. +/// This is a last-resort mechanism for when a test cannot continue due to an error in the plugin being tested. +/// Prefer regular error handling where possible. +/// +/// The difference between this and a regular panic is that regular panics are treated as bugs in the validator itself. +macro_rules! fail_test { + ($($arg:tt)*) => { + std::panic::panic_any($crate::cli::TestFailure(format!($($arg)*))) + }; +} + +pub(crate) use fail_test; diff --git a/src/cli/print.rs b/src/cli/print.rs new file mode 100644 index 0000000..6ac265b --- /dev/null +++ b/src/cli/print.rs @@ -0,0 +1,116 @@ +use std::fmt::{Display, Write}; +use textwrap::core::display_width; +use yansi::Paint; + +/// A pretty-printed report that consists of a header, a body with multiple items, and an optional footer. +/// The body items can be tables, text, or sub-reports. +#[derive(Debug, Default)] +pub struct Report { + pub header: String, + pub footer: Vec, + pub items: Vec, +} + +#[derive(Debug)] +pub enum ReportItem { + Table(Vec<(String, String)>), + Text(String), + Child(Report), +} + +impl Report { + fn print_width(&self, width: usize) -> String { + let mut result = String::new(); + + let pipe = "│".dim(); + let bar = "─".dim(); + let ctl = "┌".dim(); + let cbl = "â””".dim(); + + // Print the header text + writeln!(result, "{}{} {}", ctl, bar, self.header.bold()).ok(); + + // Print the body + for item in &self.items { + match item { + ReportItem::Text(text) => { + for line in pretty_wrap(text, width.saturating_sub(2)) { + writeln!(result, "{} {}", pipe, line).ok(); + } + } + + ReportItem::Child(child) => { + writeln!(result, "{} ", pipe).ok(); + + let child = child.print_width(width.saturating_sub(2)); + for line in child.lines() { + writeln!(result, "{} {}", pipe, line).ok(); + } + } + + ReportItem::Table(rows) => { + let max_key_len = rows.iter().map(|(k, _)| display_width(k)).max().unwrap_or(0); + + for (key, value) in rows { + for (index, line) in pretty_wrap(value, width.saturating_sub(2 + max_key_len)) + .into_iter() + .enumerate() + { + let pad = if index == 0 { + format!("{:width$}", key, width = max_key_len) + } else { + " ".repeat(max_key_len) + }; + + writeln!(result, "{} {} {}", pipe, pad.dim().italic(), line).ok(); + } + } + } + } + } + + // Print the footer line + write!(result, "{}{}{} ", cbl, bar, bar).ok(); + + // Print footer text + for (i, footer) in self.footer.iter().enumerate() { + if i > 0 { + write!(result, " {} ", bar).ok(); + } + + write!(result, "{}", footer).ok(); + } + + result + } +} + +impl Display for Report { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.print_width(match textwrap::termwidth() { + w if w > 40 => w - 10, + w => w, + },) + ) + } +} + +pub fn pluralize(count: usize, singular: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{} {}s", count, singular) + } +} + +pub fn pretty_wrap(text: &str, width: usize) -> Vec> { + textwrap::wrap( + text, + textwrap::Options::new(width) + .break_words(true) + .wrap_algorithm(textwrap::WrapAlgorithm::OptimalFit(Default::default())), + ) +} diff --git a/src/cli/sandbox.rs b/src/cli/sandbox.rs new file mode 100644 index 0000000..edad767 --- /dev/null +++ b/src/cli/sandbox.rs @@ -0,0 +1,134 @@ +use crate::cli::timebase; +use crate::commands::Verbosity; +use crate::fuzz::SandboxedFuzzChunk; +use crate::plugin::index::SandboxedScanLibrary; +use crate::validator::SandboxedValidation; +use clap::{Args, ValueEnum}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::process::ExitStatus; +use std::time::Duration; +use wait_timeout::ChildExt; + +#[derive(Debug)] +pub struct SandboxConfig { + pub hide_output: bool, + pub verbosity: Verbosity, + pub timeout: Option, +} + +#[derive(Debug)] +pub enum SandboxError { + Timeout(Duration), + Crashed(ExitStatus), +} + +#[derive(Serialize, Deserialize, Args)] +pub struct SandboxPayload { + sandbox_id: String, + sandbox_data: String, + output_file: String, +} + +pub trait SandboxOperation: Serialize + DeserializeOwned { + const ID: &'static str; + + type Result: Serialize + DeserializeOwned; + + fn run(&self) -> Self::Result; + + fn run_sandboxed(&self, config: SandboxConfig) -> Result { + let output_file = tempfile::Builder::new() + .suffix(".json") + .tempfile() + .expect("Could not create a temporary file path") + .into_temp_path(); + + let mut command = std::process::Command::new( + std::env::current_exe().expect("Could not get the path to the current executable"), + ); + + command.env( + "CLAP_VALIDATOR_TIMEBASE", + timebase() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + .to_string(), + ); + command.env( + "CLAP_VALIDATOR_VERBOSITY", + config.verbosity.to_possible_value().unwrap().get_name(), + ); + + command.arg("sandbox"); + command.arg(Self::ID); + command.arg(serde_json::to_string(&self).expect("Failed to serialize sandbox data")); + command.arg(output_file.to_str().unwrap()); + + if config.hide_output { + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + } + + let status = match config.timeout { + None => command + .spawn() + .expect("Failed to spawn a child process") + .wait() + .expect("Failed to wait for the child process"), + Some(timeout) => match command + .spawn() + .expect("Failed to spawn a child process") + .wait_timeout(timeout) + .expect("Failed to wait for the child process") + { + Some(status) => status, + None => return Err(SandboxError::Timeout(timeout)), + }, + }; + + if !status.success() { + return Err(SandboxError::Crashed(status)); + } + + let output = + std::fs::read_to_string(&output_file).expect("Failed to read the output file from the sandboxed operation"); + let result: Self::Result = + serde_json::from_str(&output).expect("Failed to deserialize the output from the sandboxed operation"); + + Ok(result) + } +} + +impl SandboxPayload { + pub fn dispatch(self) { + fn dispatch(payload: &SandboxPayload) { + let operation: T = + serde_json::from_str(&payload.sandbox_data).expect("Failed to deserialize the sandbox data"); + let result = operation.run(); + std::fs::write( + &payload.output_file, + serde_json::to_string(&result).expect("Failed to serialize the sandbox result"), + ) + .expect("Failed to write the sandbox result to the output file"); + } + + match self.sandbox_id.as_str() { + SandboxedScanLibrary::ID => dispatch::(&self), + SandboxedValidation::ID => dispatch::(&self), + SandboxedFuzzChunk::ID => dispatch::(&self), + _ => panic!("Unknown sandbox ID"), + }; + } +} + +impl std::error::Error for SandboxError {} +impl std::fmt::Display for SandboxError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + SandboxError::Timeout(duration) => write!(f, "Timed out after {} seconds", duration.as_secs()), + SandboxError::Crashed(status) => write!(f, "{}", status), + } + } +} diff --git a/src/cli/tracing.rs b/src/cli/tracing.rs new file mode 100644 index 0000000..35d1c85 --- /dev/null +++ b/src/cli/tracing.rs @@ -0,0 +1,23 @@ +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +mod record; +mod writer; + +pub use record::*; + +static WRITER: OnceLock> = OnceLock::new(); + +pub fn install(path: &Path) { + WRITER + .set(Mutex::new(writer::TraceWriter::new(path))) + .map_err(|_| ()) + .expect("instrumentation already started"); +} + +pub fn check_error() -> Result<(), String> { + match WRITER.get() { + Some(writer) => writer.lock().unwrap().check_error().map_err(|x| x.to_string()), + None => Err("instrumentation not started".to_string()), + } +} diff --git a/src/cli/tracing/record.rs b/src/cli/tracing/record.rs new file mode 100644 index 0000000..d1b2dd4 --- /dev/null +++ b/src/cli/tracing/record.rs @@ -0,0 +1,140 @@ +use super::WRITER; +use std::fmt::Display; + +pub fn event(message: impl Display, context: impl Recordable) { + if let Some(writer) = WRITER.get() { + writer.lock().unwrap().write( + format_args!("{}", message), + std::thread::current().name().unwrap_or("?"), + "n", + &context, + ); + } +} + +pub struct Span { + name: &'static str, +} + +impl Drop for Span { + fn drop(&mut self) { + Span { name: self.name }.finish(()) + } +} + +impl Span { + pub fn begin(name: &'static str, context: impl Recordable) -> Self { + if let Some(writer) = WRITER.get() { + writer.lock().unwrap().write( + format_args!("{}", name), + std::thread::current().name().unwrap_or("?"), + "b", + &context, + ); + } + + Self { name } + } + + pub fn finish(self, context: T) { + if let Some(writer) = WRITER.get() { + writer.lock().unwrap().write( + format_args!("{}", self.name), + std::thread::current().name().unwrap_or("?"), + "e", + &context, + ); + } + + std::mem::forget(self); + } + + pub fn name(&self) -> &'static str { + self.name + } +} + +pub trait Recorder { + fn record_value(&mut self, value: std::fmt::Arguments<'_>); + fn record_entry(&mut self, name: &str, record: &dyn Recordable); +} + +pub trait Recordable { + fn record(&self, record: &mut dyn Recorder); +} + +impl dyn Recorder + '_ { + pub fn record(&mut self, name: &str, value: T) { + self.record_entry(name, &value); + } +} + +impl Recordable for () { + fn record(&self, _: &mut dyn Recorder) {} +} + +impl Recordable for &T { + fn record(&self, record: &mut dyn Recorder) { + (*self).record(record); + } +} + +impl Recordable for Option { + fn record(&self, record: &mut dyn Recorder) { + if let Some(value) = self { + value.record(record); + } + } +} + +macro_rules! impl_display { + ($($ty:ty),*) => { + $(impl Recordable for $ty { + fn record(&self, record: &mut dyn Recorder) { + record.record_value(format_args!("{}", self)); + } + })* + }; +} + +impl_display!( + bool, + i8, + i16, + i32, + i64, + i128, + isize, + u8, + u16, + u32, + u64, + u128, + usize, + f32, + f64, + str, + String, + std::borrow::Cow<'_, str>, + std::fmt::Arguments<'_> +); + +pub fn from_fn(f: impl Fn(&mut dyn Recorder)) -> impl Recordable { + struct FnRecord(F); + impl Recordable for FnRecord { + fn record(&self, record: &mut dyn Recorder) { + (self.0)(record); + } + } + FnRecord(f) +} + +macro_rules! record { + ($($name:ident: $value:expr),*) => {{ + $crate::cli::tracing::from_fn(|record| { + $(record.record_entry(stringify!($name), &$value as &dyn $crate::cli::tracing::Recordable);)* + }) + }}; +} + +pub(crate) use record; diff --git a/src/cli/tracing/writer.rs b/src/cli/tracing/writer.rs new file mode 100644 index 0000000..52c3e49 --- /dev/null +++ b/src/cli/tracing/writer.rs @@ -0,0 +1,98 @@ +use super::{Recordable, Recorder}; +use std::fs::File; +use std::io::{BufWriter, Error, Write}; +use std::path::Path; +use std::time::Instant; + +pub struct TraceWriter { + file: Result, Error>, + start: Instant, +} + +impl TraceWriter { + pub fn new>(path: P) -> Self { + Self { + file: File::create(path) + .map(BufWriter::new) + .and_then(|mut f| f.write_all(b"[\n").map(|_| f)), + start: Instant::now(), + } + } + + pub fn check_error(&self) -> Result<(), &Error> { + self.file.as_ref().map(|_| ()) + } + + pub fn write(&mut self, name: std::fmt::Arguments<'_>, cat: &str, tag: &str, args: &A) { + if let Ok(file) = &mut self.file { + let result = serde_json::to_writer( + &mut *file, + &TraceEvent { + name, + args: RecordableAsSerde(args), + ts: self.start.elapsed().as_micros(), + ph: tag, + cat, + id: 1, + pid: 1, + }, + ) + .map_err(Error::other) + .and_then(|_| file.write_all(b",\n")) + .and_then(|_| file.flush()); + + if let Err(e) = result { + self.file = Err(e); + } + } + } +} + +/// An event that is written to the file +#[derive(serde::Serialize)] +struct TraceEvent<'a, N: serde::Serialize, A: serde::Serialize> { + name: N, + cat: &'a str, + ts: u128, + id: u64, + pid: u64, + ph: &'a str, + args: A, +} + +struct RecordableAsSerde(T); + +impl serde::Serialize for RecordableAsSerde { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + + struct SerdeRecorder { + serializer: S, + state: Result<(), S::Error>, + } + + impl Recorder for SerdeRecorder { + fn record_value(&mut self, value: std::fmt::Arguments<'_>) { + self.state = self.serializer.serialize_entry("", &value); + } + + fn record_entry(&mut self, name: &str, record: &dyn Recordable) { + if name.is_empty() { + record.record(self); + } else { + self.state = self.serializer.serialize_entry(name, &RecordableAsSerde(record)); + } + } + } + + let mut recorder = SerdeRecorder { + serializer: serializer.serialize_map(None)?, + state: Ok(()), + }; + + self.0.record(&mut recorder); + + recorder.state?; + recorder.serializer.end() + } +} diff --git a/src/commands.rs b/src/commands.rs index 2c4b6cc..766ee44 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,82 +1,51 @@ //! All the different commands for the cli. Split up into modules and functions to make it a bit //! easier to navigate. -use std::collections::HashMap; - +pub mod fuzz; pub mod list; pub mod validate; -/// A helper for printing terminal wrapped and indentend strings to STDOUT. -pub struct TextWrapper { - /// The basic wrapping options, minus the indent string. - wrapping_options: textwrap::Options<'static>, - /// Indent strings for different widths. Need to be allocated separately because textwrap - /// doesn't let you directly indent to a certain number of spaces. - indent_strings: HashMap, -} +use clap::*; -impl Default for TextWrapper { - fn default() -> Self { - Self { - wrapping_options: textwrap::Options::with_termwidth(), - indent_strings: HashMap::new(), - } - } -} +#[derive(Parser)] +#[command(author, version, about, long_about = None, propagate_version = true)] +pub struct Arguments { + /// clap-validator's own logging verbosity. + /// + /// This can be used to silence all non-essential output, or to enable more in depth tracing. + #[arg(short, long, default_value = "info", env = "CLAP_VALIDATOR_VERBOSITY")] + pub verbosity: Verbosity, -/// Shorthand for `wrapper.print_auto(format!(...))`. -macro_rules! println_wrapped { - ($wrapper:expr, $($arg:tt)*) => { - $wrapper.print_auto(format!($($arg)*)) - } + #[command(subcommand)] + pub command: Command, } -pub(crate) use println_wrapped; -/// Shorthand for `wrapper.print_auto_no_indent(format!(...))`. -macro_rules! println_wrapped_no_indent { - ($wrapper:expr, $($arg:tt)*) => { - $wrapper.print_auto_no_indent(format!($($arg)*)) - } -} -pub(crate) use println_wrapped_no_indent; +/// The validator's subcommands. +#[derive(Subcommand)] +pub enum Command { + /// Validate one or more plugins. + Validate(validate::ValidatorSettings), -impl TextWrapper { - /// Print a string to STDOUT wrapped to the terminal width using the given subsequent indent - /// width. The first line is not automatically indented so you can use bullets and other - /// formatting characters. - pub fn print(&mut self, subsequent_indent_width: usize, text: impl AsRef) { - let indent_string = self - .indent_strings - .entry(subsequent_indent_width) - .or_insert_with(|| " ".repeat(subsequent_indent_width)); - let wrapping_options = self - .wrapping_options - .clone() - .subsequent_indent(indent_string); - println!("{}", textwrap::fill(text.as_ref(), wrapping_options)); - } + /// Fuzz a plugin. + Fuzz(fuzz::FuzzSettings), - /// The same as [`print()`][Self::print()], but it uses a heuristic to guess the subsequent - /// indent width. This is the number of space and dash characters the input starts with, plus - /// two. - pub fn print_auto(&mut self, text: impl AsRef) { - let indent_width = Self::auto_indent_width(&text) + 2; + /// List available tests, scan plugins, presets, etc. + #[command(subcommand)] + List(list::ListCommand), - self.print(indent_width, text) - } - - /// The same as [`print_auto()`][Self::print_auto()], but doesn't indent subsequent lines. - pub fn print_auto_no_indent(&mut self, text: impl AsRef) { - let indent_width = Self::auto_indent_width(&text); - - self.print(indent_width, text) - } + #[command(hide = true)] + Sandbox(crate::cli::sandbox::SandboxPayload), +} - /// The number of characters until the start of the string, ignoring spaces and dashes. - fn auto_indent_width(text: impl AsRef) -> usize { - text.as_ref() - .chars() - .take_while(|&c| c == ' ' || c == '-') - .count() - } +/// The verbosity level. Set to `Debug` by default. `Trace` can be used to get more information on +/// what the validator is actually doing. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Verbosity { + /// Suppress all logging output from the validator itself. + Quiet, + Error, + Warn, + Info, + Debug, + Trace, } diff --git a/src/commands/fuzz.rs b/src/commands/fuzz.rs new file mode 100644 index 0000000..b2051c2 --- /dev/null +++ b/src/commands/fuzz.rs @@ -0,0 +1,142 @@ +use crate::cli::{Report, ReportItem}; +use crate::commands::Verbosity; +use crate::fuzz::{FuzzResult, FuzzStatus}; +use anyhow::Result; +use clap::Args; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; +use std::vec; +use yansi::Paint; + +fn parse_duration(mut str: &str) -> Result { + if str.is_empty() { + return Err("no duration provided"); + } + + let mut duration = Duration::from_secs(0); + while !str.is_empty() { + let (num, rest) = str + .trim_ascii_start() + .split_at(str.find(|c: char| !c.is_ascii_digit()).unwrap_or(str.len())); + let (unit, rest) = rest + .trim_ascii_start() + .split_at(rest.find(|c: char| c.is_ascii_digit()).unwrap_or(rest.len())); + + let num: u64 = num.parse::().map_err(|_| "invalid duration format")?; + let unit = match unit { + "ms" | "millis" => Duration::from_millis(num), + "s" | "sec" | "seconds" => Duration::from_secs(num), + "m" | "min" | "minutes" => Duration::from_secs(num * 60), + "h" | "hr" | "hrs" | "hour" | "hours" => Duration::from_secs(num * 60 * 60), + _ => return Err("invalid duration format"), + }; + + duration += unit; + str = rest; + } + + Ok(duration) +} + +/// Options for the fuzzer. +#[derive(Debug, Args)] +pub struct FuzzSettings { + /// Paths to one or more plugins that should be fuzzed. + #[arg(required = true)] + pub paths: Vec, + + /// Only fuzz plugins with this ID. + /// + /// If the plugin library contains multiple plugins, then you can pass a single plugin's ID + /// to this option to only fuzz that plugin. Otherwise all plugins in the library are + /// fuzzed. + #[arg(short = 'p', long)] + pub plugin_id: Option, + + /// Print the test output as JSON instead of human readable text. + #[arg(long)] + pub json: bool, + + /// Run the fuzzer for this long before stopping. + /// By default it will run until stopped manually via Ctrl+C. + #[arg(long, short = 'd', value_parser = parse_duration)] + pub duration: Option, + + /// When running the fuzzer out-of-process, this many fuzzing chunks will be run in parallel. + /// + /// By default this is set to the number of logical CPU cores. + #[arg(long, short = 'j')] + pub jobs: Option, + + /// When running the validation in-process, emit a JSON trace file that can be viewed with + /// Chrome's tracing viewer or . + /// + /// This has a non-negligible performance impact. + #[arg(long, requires = "reproduce")] + pub trace: bool, + + /// How many errors to collect before stopping the fuzzer. + #[arg(long, short = 'l', default_value = "1")] + pub limit: usize, + + /// Run the fuzzer with this random seed in-process. + /// + /// This will run a single deterministic fuzzing chunk that will execute the same sequence of calls every time. + /// Useful for reproducing an error/crash produced by the out-of-process fuzzer. + #[arg( + long, + short = 'r', + conflicts_with = "jobs", + conflicts_with = "duration", + conflicts_with = "limit" + )] + pub reproduce: Option, +} + +/// The main fuzzer command. This will fuzz one or more plugins and print the results. +pub fn fuzz(verbosity: Verbosity, settings: FuzzSettings) -> Result { + let result = crate::fuzz::fuzz(verbosity, &settings)?; + + if settings.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else if result.is_empty() { + eprintln!("{}: fuzzing finished successfully", "OK".green().bold()); + } else { + pretty_print(&result); + } + + if result.iter().all(|result| result.status == FuzzStatus::Success) { + Ok(ExitCode::SUCCESS) + } else { + Ok(ExitCode::FAILURE) + } +} + +pub fn pretty_print(result: &[FuzzResult]) { + for result in result { + let status_text = match result.status { + FuzzStatus::Success => "OK".green(), + FuzzStatus::Failed { .. } => "FAILED".red(), + FuzzStatus::Crashed { .. } => "CRASHED".red().bold(), + }; + + let details = match &result.status { + FuzzStatus::Success => None, + FuzzStatus::Failed { details } => Some(details), + FuzzStatus::Crashed { details } => Some(details), + }; + + let mut report = Report { + header: result.plugin_id.to_string(), + footer: vec![status_text.to_string(), result.seed.dim().to_string()], + items: vec![], + }; + + if let Some(details) = details { + report.items.push(ReportItem::Text(details.to_string())); + } + + eprintln!("\n{}", report); + } +} diff --git a/src/commands/list.rs b/src/commands/list.rs index d4bf907..e17f89e 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,367 +1,665 @@ //! Commands for listing information about the validator or installed plugins. +use crate::Verbosity; +use crate::cli::IteratorExt; +use crate::cli::sandbox::{SandboxConfig, SandboxOperation}; +use crate::plugin::index::{SandboxedScanLibrary, ScanStatus, index_plugins}; use anyhow::{Context, Result}; -use colored::Colorize; -use std::path::Path; +use clap::Subcommand; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; use std::process::ExitCode; +use strum::IntoEnumIterator; -use super::{println_wrapped, println_wrapped_no_indent, TextWrapper}; -use crate::index::PresetIndexResult; -use crate::plugin::preset_discovery::PresetFile; +/// Commands for listing tests and data realted to the installed plugins. +#[derive(Subcommand)] +pub enum ListCommand { + /// Lists basic information about all installed CLAP plugins. + Plugins { + /// Print JSON instead of a human readable format. + #[arg(short, long)] + json: bool, + /// Run the plugin indexing in-process instead of out-of-process. + #[arg(long)] + in_process: bool, + /// When running the scans out-of-process, hide the plugin's output. + #[arg(long, conflicts_with = "in_process")] + hide_output: bool, + /// Paths to one or more plugins that should be loaded and scanned, optional. + /// + /// All installed plugins are crawled if this value is missing. + paths: Option>, + }, + /// Lists the available presets for one, more, or all installed CLAP plugins. + Presets { + /// Print JSON instead of a human readable format. + #[arg(short, long)] + json: bool, + /// Run the plugin indexing in-process instead of out-of-process. + #[arg(long)] + in_process: bool, + /// When running the scans out-of-process, hide the plugin's output. + #[arg(long, conflicts_with = "in_process")] + hide_output: bool, + /// Paths to one or more plugins that should be indexed for presets, optional. + /// + /// All installed plugins are crawled if this value is missing. + paths: Option>, + /// Limit the number of presets printed per plugin. Only applies to the human readable output. + #[arg(short, long, conflicts_with = "json")] + limit: Option, + }, -// TODO: The indexing here always happens in the same process. We should move this over to out of -// process scanning at some point. + /// Lists all available test cases. + Tests { + /// Print JSON instead of a human readable format. + #[arg(short, long)] + json: bool, + }, +} + +pub fn list(verbosity: Verbosity, command: ListCommand) -> Result { + match command { + ListCommand::Tests { json } => list_tests(json), + ListCommand::Plugins { + json, + in_process, + hide_output, + paths, + } => list_plugins(json, in_process, hide_output, verbosity, paths), + ListCommand::Presets { + json, + in_process, + hide_output, + paths, + limit, + } => list_presets( + json, + in_process, + hide_output, + verbosity, + paths, + limit.unwrap_or(usize::MAX), + ), + } +} + +/// List presets for one, more, or all installed CLAP plugins. +fn list_presets( + json: bool, + in_process: bool, + hide_output: bool, + verbosity: Verbosity, + paths: Option>, + preset_limit: usize, +) -> Result { + let plugins = match paths { + Some(paths) => paths, + None => index_plugins().context("Error while crawling plugins")?, + }; + + let results = plugins + .into_iter() + .parallel_map(in_process.then_some(1), |path| { + scan_single(path, in_process, true, hide_output, verbosity) + }) + .collect::>>()?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&results).expect("Could not format JSON") + ); + } else { + pretty::print_presets(results, preset_limit); + } + + Ok(ExitCode::SUCCESS) +} /// Lists basic information about all installed CLAP plugins. -pub fn plugins(json: bool) -> Result { - let plugin_index = crate::index::index(); +fn list_plugins( + json: bool, + in_process: bool, + hide_output: bool, + verbosity: Verbosity, + paths: Option>, +) -> Result { + let plugins = match paths { + Some(paths) => paths, + None => index_plugins().context("Error while crawling plugins")?, + }; + + let results = plugins + .into_iter() + .parallel_map(in_process.then_some(1), |path| { + scan_single(path, in_process, false, hide_output, verbosity) + }) + .collect::>>()?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&results).expect("Could not format JSON") + ); + } else { + pretty::print_plugins(results); + } + + Ok(ExitCode::SUCCESS) +} + +/// Lists all available test cases. +fn list_tests(json: bool) -> Result { + #[derive(Serialize)] + #[serde(tag = "type", rename_all = "kebab-case")] + enum TestJson { + PluginLibrary { name: String, description: String }, + PluginInstance { name: String, description: String }, + } if json { + let mut list = Vec::new(); + + for test in crate::tests::PluginLibraryTestCase::iter() { + list.push(TestJson::PluginLibrary { + name: test.to_string(), + description: test.description().to_string(), + }); + } + + for test in crate::tests::PluginInstanceTestCase::iter() { + list.push(TestJson::PluginInstance { + name: test.to_string(), + description: test.description().to_string(), + }); + } + println!( "{}", - serde_json::to_string_pretty(&plugin_index).expect("Could not format JSON") + serde_json::to_string_pretty(&list).expect("Could not format JSON") ); } else { - let mut wrapper = TextWrapper::default(); - for (i, (plugin_path, metadata)) in plugin_index.0.into_iter().enumerate() { - if i > 0 { - println!(); + let config = crate::cli::Config::from_current()?; + pretty::print_tests(&config); + } + + Ok(ExitCode::SUCCESS) +} + +fn scan_single( + path: PathBuf, + in_process: bool, + scan_presets: bool, + hide_output: bool, + verbosity: Verbosity, +) -> Result<(PathBuf, ScanStatus)> { + let request = SandboxedScanLibrary { + library_path: path.clone(), + scan_presets, + }; + + let result = match in_process { + true => request.run(), + false => request + .run_sandboxed(SandboxConfig { + verbosity, + hide_output, + timeout: Some(std::time::Duration::from_secs(10)), + }) + .unwrap_or_else(|err| ScanStatus::Crashed { + details: err.to_string(), + }), + }; + + Ok((path, result)) +} + +mod pretty { + use crate::cli::{Config, Report, ReportItem, pluralize}; + use crate::plugin::index::ScanStatus; + use crate::plugin::preset_discovery::*; + use crate::tests::{PluginInstanceTestCase, PluginLibraryTestCase}; + use std::path::PathBuf; + use strum::IntoEnumIterator; + use yansi::Paint; + + pub fn print_tests(config: &Config) { + let report_test = |name: &str, description: &str| { + let mut report = Report { + header: name.to_string(), + items: vec![ReportItem::Text(description.to_string())], + footer: vec![], + }; + + if !config.is_test_enabled(name) { + report.footer.push("disabled".dim().italic().to_string()); } - println_wrapped!( - wrapper, - "{}: (CLAP {}.{}.{}, contains {} {})", - plugin_path.display(), - metadata.version.0, - metadata.version.1, - metadata.version.2, - metadata.plugins.len(), - if metadata.plugins.len() == 1 { - "plugin" - } else { - "plugins" - }, - ); - - for plugin in metadata.plugins { - println!(); - println_wrapped!( - wrapper, - " - {} {} ({})", + report + }; + + let plugin_library_tests = PluginLibraryTestCase::iter().collect::>(); + let plugin_instance_tests = PluginInstanceTestCase::iter().collect::>(); + + let mut library = Report { + header: "Plugin Library".to_string(), + items: vec![ReportItem::Text( + "Tests for plugin factories, preset providers and plugin libraries (files) in general".to_string(), + )], + footer: vec![pluralize(plugin_library_tests.len(), "test")], + }; + + let mut plugin = Report { + header: "Plugin".to_string(), + items: vec![ReportItem::Text( + "Tests for specific plugins within libraries, including their behavior during initialization, \ + deinitialization, audio processing and callback handling." + .to_string(), + )], + footer: vec![pluralize(plugin_instance_tests.len(), "test")], + }; + + for test in &plugin_library_tests { + library + .items + .push(ReportItem::Child(report_test(&test.to_string(), &test.description()))); + } + + for test in &plugin_instance_tests { + plugin + .items + .push(ReportItem::Child(report_test(&test.to_string(), &test.description()))); + } + + println!("\n{}", library); + println!("\n{}", plugin); + } + + pub fn print_plugins(results: impl IntoIterator) { + let mut num_errors = 0; + let mut num_files = 0; + let mut num_plugins = 0; + + for (plugin_path, status) in results.into_iter() { + // add to the tally + num_files += 1; + + // handle and print errors if necessary + let (library, duration) = match status { + ScanStatus::Error { details } => { + num_errors += 1; + + let report = Report { + header: plugin_path.display().to_string(), + items: vec![ReportItem::Text(details)], + footer: vec!["ERROR".red().to_string()], + }; + + println!("\n{}", report); + continue; + } + + ScanStatus::Crashed { details } => { + num_errors += 1; + + let report = Report { + header: plugin_path.display().to_string(), + items: vec![ReportItem::Text(details)], + footer: vec!["CRASHED".red().bold().to_string()], + }; + + println!("\n{}", report); + continue; + } + + ScanStatus::Success { library, duration } => (library, duration), + }; + + // plugin library info + let mut group = Report { + header: plugin_path.display().to_string(), + + items: vec![ReportItem::Text(format!( + "CLAP {}.{}.{}", + library.version.0, library.version.1, library.version.2 + ))], + + footer: vec![ + "OK".green().to_string(), + pluralize(library.plugins.len(), "plugin"), + format!("{}ms", duration.as_millis()).dim().to_string(), + ], + }; + + // per plugin info + for plugin in library.plugins { + num_plugins += 1; + + let mut report = Report { + header: plugin.id, + ..Default::default() + }; + + report.items.push(ReportItem::Text(format!( + "{} {} ({})", plugin.name, plugin.version.as_deref().unwrap_or("(unknown version)"), - plugin.id - ); + plugin.vendor.as_deref().unwrap_or("unknown vendor"), + ))); - // Whether it makes sense to always show optional fields or not depends on - // the field if let Some(description) = plugin.description { - println_wrapped_no_indent!(wrapper, " {description}"); + report.items.push(ReportItem::Text(description)); + } + + let mut metadata = vec![]; + + if let Some(url) = plugin.url { + metadata.push(("url".to_string(), url)); } - println!(); - println_wrapped!( - wrapper, - " vendor: {}", - plugin.vendor.as_deref().unwrap_or("(unknown)") - ); + if let Some(manual_url) = plugin.manual_url { - println_wrapped!(wrapper, " manual url: {manual_url}"); + metadata.push(("manual url".to_string(), manual_url)); } + if let Some(support_url) = plugin.support_url { - println_wrapped!(wrapper, " support url: {support_url}"); + metadata.push(("support url".to_string(), support_url)); + } + + if !plugin.features.is_empty() { + metadata.push(("features".to_string(), plugin.features.join(" "))); } - println_wrapped!(wrapper, " features: [{}]", plugin.features.join(", ")); + + report.items.push(ReportItem::Table(metadata)); + group.items.push(ReportItem::Child(report)); } + + println!("\n{}", group); } + + println!( + "{}, {}, {}", + pluralize(num_files, "file"), + pluralize(num_plugins, "plugin"), + pluralize(num_errors, "error") + ) } - Ok(ExitCode::SUCCESS) -} + pub fn print_presets(results: impl IntoIterator, preset_limit: usize) { + fn report_preset(preset: &Preset, key: &str, location: &str) -> Report { + let mut metadata = vec![]; + + if !location.is_empty() { + metadata.push(("location".to_string(), location.to_string())); + } + + if !key.is_empty() { + metadata.push(("key".to_string(), key.to_string())); + } + + if !preset.plugin_ids.is_empty() { + metadata.push(( + "plugins".to_string(), + preset + .plugin_ids + .iter() + .map(|id| match &id.abi { + PluginAbi::Clap => id.id.clone(), + PluginAbi::Other(abi) => format!("{}:{}", abi, id.id), + }) + .collect::>() + .join(" "), + )); + } + + if let Some(soundpack_id) = &preset.soundpack_id { + metadata.push(("soundpack".to_string(), soundpack_id.clone())); + } -/// Lists presets for one, more, or all plugins. -pub fn presets

(json: bool, plugin_paths: Option<&[P]>) -> Result -where - P: AsRef, -{ - let preset_index = match plugin_paths { - Some(plugin_paths) => crate::index::index_presets(plugin_paths, false), - None => { - let plugin_index = crate::index::index(); - let all_plugin_paths = plugin_index.0.keys(); - - // This 'true' indicates that plugins that don't support the preset discovery mechanism - // should be silently skipped - crate::index::index_presets(all_plugin_paths, true) + if let Some(creation_time) = preset.creation_time { + metadata.push(("created".to_string(), creation_time.to_string())); + } + + if let Some(modification_time) = preset.modification_time { + metadata.push(("modified".to_string(), modification_time.to_string())); + } + + if !preset.creators.is_empty() { + metadata.push(("creators".to_string(), preset.creators.join("; "))); + } + + if !preset.features.is_empty() { + metadata.push(("features".to_string(), preset.features.join("; "))); + } + + for (key, value) in &preset.extra_info { + metadata.push((key.clone(), value.clone())); + } + + let flags = { + let mut flags = vec![]; + if preset.flags.is_inherited { + flags.push("inherited"); + } + if preset.flags.flags.is_favorite { + flags.push("favorite"); + } + if preset.flags.flags.is_factory_content { + flags.push("factory"); + } + if preset.flags.flags.is_demo_content { + flags.push("demo"); + } + if preset.flags.flags.is_user_content { + flags.push("user"); + } + flags.join(" ") + }; + + if !flags.is_empty() { + metadata.push(("flags".to_string(), flags)); + } + + let mut report = Report { + header: "Preset".to_string(), + items: vec![], + footer: vec![], + }; + + report.items.push(ReportItem::Text(preset.name.to_string())); + + if let Some(description) = &preset.description { + report.items.push(ReportItem::Text(description.to_string())); + } + + if !metadata.is_empty() { + report.items.push(ReportItem::Table(metadata)); + } + + report } - } - .context("Error while crawling presets")?; - let has_errors = preset_index - .0 - .values() - .any(|result| matches!(result, PresetIndexResult::Error(_))); - if json { - println!( - "{}", - serde_json::to_string_pretty(&preset_index).expect("Could not format JSON") - ); - } else { - let mut wrapper = TextWrapper::default(); - for (i, (plugin_path, result)) in preset_index.0.into_iter().enumerate() { - if i > 0 { - println!(); + fn report_soundpack(soundpack: &Soundpack) -> Report { + let mut report = Report { + header: "Soundpack".to_string(), + ..Default::default() + }; + + report.items.push(ReportItem::Text(format!( + "{} ({})", + soundpack.name, + soundpack.vendor.as_deref().unwrap_or("unknown vendor") + ))); + + if let Some(description) = &soundpack.description { + report.items.push(ReportItem::Text(description.to_string())); } - let provider_results = match result { - PresetIndexResult::Success(provider_results) => provider_results, - PresetIndexResult::Error(error) => { - println_wrapped!(wrapper, "{}:", plugin_path.display()); - println!(); - println_wrapped!(wrapper, " {}: {}", "FAILED".red(), error); - continue; + let mut metadata = vec![]; + + metadata.push(("id".to_string(), soundpack.id.clone())); + + if let Some(image_path) = &soundpack.image_path { + metadata.push(("image".to_string(), image_path.to_string())); + } + + if let Some(homepage_url) = &soundpack.homepage_url { + metadata.push(("homepage".to_string(), homepage_url.clone())); + } + + if let Some(release_timestamp) = soundpack.release_timestamp { + metadata.push(("released".to_string(), release_timestamp.to_string())); + } + + let flags = { + let mut flags = vec![]; + if soundpack.flags.is_favorite { + flags.push("favorite"); + } + if soundpack.flags.is_factory_content { + flags.push("factory"); + } + if soundpack.flags.is_demo_content { + flags.push("demo"); } + if soundpack.flags.is_user_content { + flags.push("user"); + } + flags.join(" ") }; - println_wrapped!( - wrapper, - "{}: (contains {} {})", - plugin_path.display(), - provider_results.len(), - if provider_results.len() == 1 { - "preset provider" - } else { - "preset providers" + if !flags.is_empty() { + metadata.push(("flags".to_string(), flags)); + } + + report + } + + for (plugin_path, status) in results.into_iter() { + let (library, duration) = match status { + ScanStatus::Error { details } => { + let report = Report { + header: plugin_path.display().to_string(), + items: vec![ReportItem::Text(details)], + footer: vec!["ERROR".red().to_string()], + }; + + println!("\n{}", report); + continue; } - ); - println!(); - for (i, provider_result) in provider_results.into_iter().enumerate() { - if i > 0 { - println!(); + ScanStatus::Crashed { details } => { + let report = Report { + header: plugin_path.display().to_string(), + items: vec![ReportItem::Text(details)], + footer: vec!["CRASHED".red().bold().to_string()], + }; + + println!("\n{}", report); + continue; } - println_wrapped!( - wrapper, - " - {} ({}) (contains {} {}, {} {}):", - provider_result.provider_name, - provider_result - .provider_vendor - .as_deref() - .unwrap_or("unknown vendor"), - provider_result.soundpacks.len(), - if provider_result.soundpacks.len() == 1 { - "soundpack" - } else { - "soundpacks" - }, - provider_result.presets.len(), - if provider_result.presets.len() == 1 { - "preset" - } else { - "presets" - }, - ); - - if !provider_result.soundpacks.is_empty() { - println!(); - println!(" Soundpacks:"); - - for soundpack in provider_result.soundpacks { - println!(); - println_wrapped!(wrapper, " - {} ({})", soundpack.name, soundpack.id); - if let Some(description) = soundpack.description { - println_wrapped_no_indent!(wrapper, " {}", description); - } - println!(); - println_wrapped!( - wrapper, - " vendor: {}", - soundpack.vendor.as_deref().unwrap_or("(unknown)") - ); - if let Some(homepage_url) = soundpack.homepage_url { - println_wrapped!(wrapper, " homepage url: {homepage_url}"); - } - if let Some(image_path) = soundpack.image_path { - println_wrapped!(wrapper, " image path: {image_path}"); - } - if let Some(release_timestamp) = soundpack.release_timestamp { - println_wrapped!(wrapper, " released: {release_timestamp}"); - } - println_wrapped!(wrapper, " flags: {}", soundpack.flags); + ScanStatus::Success { library, duration } => { + if library.preset_providers.is_empty() { + continue; } + + (library, duration) } + }; - if !provider_result.presets.is_empty() { - println!(); - println!(" Presets:"); - - for (preset_uri, preset_file) in provider_result.presets { - println!(); - match preset_file { - PresetFile::Single(preset) => { - println_wrapped!(wrapper, " - {}", preset_uri); - - println!(); - println_wrapped!( - wrapper, - " {} ({})", - preset.name, - preset.plugin_ids_string() - ); - if let Some(description) = preset.description { - println_wrapped_no_indent!(wrapper, " {}", description); - } - println!(); - if !preset.creators.is_empty() { - println_wrapped!( - wrapper, - " {}: {}", - if preset.creators.len() == 1 { - "creator" - } else { - "creators" - }, - preset.creators.join(", ") - ); - } - if let Some(soundpack_id) = preset.soundpack_id { - println_wrapped!(wrapper, " soundpack: {soundpack_id}"); - } - if let Some(creation_time) = preset.creation_time { - println_wrapped!(wrapper, " created: {creation_time}"); - } - if let Some(modification_time) = preset.modification_time { - println_wrapped!(wrapper, " modified: {modification_time}"); - } - println_wrapped!(wrapper, " flags: {}", preset.flags); - if !preset.features.is_empty() { - println_wrapped!( - wrapper, - " features: [{}]", - preset.features.join(", ") - ); - } - if !preset.extra_info.is_empty() { - println_wrapped!( - wrapper, - " extra info: {:#?}", - preset.extra_info - ); - } - } - PresetFile::Container(presets) => { - println_wrapped!( - wrapper, - " - {} (contains {} {})", - preset_uri, - presets.len(), - if presets.len() == 1 { - "preset" - } else { - "presets" - } - ); - - for (load_key, preset) in presets { - println!(); - println_wrapped!( - wrapper, - " - {} ({}, {})", - preset.name, - load_key, - preset.plugin_ids_string() - ); - if let Some(description) = preset.description { - println_wrapped_no_indent!( - wrapper, - " {}", - description - ); - } - println!(); - if !preset.creators.is_empty() { - println_wrapped!( - wrapper, - " {}: {}", - if preset.creators.len() == 1 { - "creator" - } else { - "creators" - }, - preset.creators.join(", ") - ); - } - if let Some(soundpack_id) = preset.soundpack_id { - println_wrapped!( - wrapper, - " soundpack: {soundpack_id}" - ); - } - if let Some(creation_time) = preset.creation_time { - println_wrapped!( - wrapper, - " created: {creation_time}" - ); - } - if let Some(modification_time) = preset.modification_time { - println_wrapped!( - wrapper, - " modified: {modification_time}" - ); - } - println_wrapped!(wrapper, " flags: {}", preset.flags); - if !preset.features.is_empty() { - println_wrapped!( - wrapper, - " features: [{}]", - preset.features.join(", ") - ); - } - if !preset.extra_info.is_empty() { - println_wrapped!( - wrapper, - " extra info: {:#?}", - preset.extra_info - ); - } - } - } - } + let mut group = Report { + header: plugin_path.display().to_string(), + + items: vec![ReportItem::Text(format!( + "CLAP {}.{}.{}", + library.version.0, library.version.1, library.version.2 + ))], + + footer: vec![ + "OK".green().to_string(), + pluralize(library.preset_providers.len(), "preset provider"), + format!("{}ms", duration.as_millis()).dim().to_string(), + ], + }; + + for provider in library.preset_providers { + let mut report = Report { + header: provider.provider_id, + footer: vec![ + pluralize(provider.soundpacks.len(), "soundpack"), + pluralize(provider.presets.len(), "preset"), + ], + ..Default::default() + }; + + report.items.push(ReportItem::Text(format!( + "{} {}.{}.{} ({})", + provider.provider_name, + provider.provider_version.0, + provider.provider_version.1, + provider.provider_version.2, + provider.provider_vendor.as_deref().unwrap_or("unknown vendor"), + ))); + + for (index, soundpack) in provider.soundpacks.iter().enumerate() { + if index >= preset_limit { + report.items.push(ReportItem::Text( + format!("... and {} more soundpacks", provider.soundpacks.len() - preset_limit) + .dim() + .italic() + .to_string(), + )); + + break; } + + report.items.push(ReportItem::Child(report_soundpack(soundpack))); } - } - } - } - Ok(if has_errors { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS - }) -} + for (index, (location, preset)) in provider.presets.iter().enumerate() { + if index >= preset_limit { + report.items.push(ReportItem::Text( + format!("... and {} more presets", provider.presets.len() - preset_limit) + .dim() + .italic() + .to_string(), + )); -/// Lists all available test cases. -pub fn tests(json: bool) -> Result { - let list = crate::tests::TestList::default(); + break; + } - if json { - println!( - "{}", - serde_json::to_string_pretty(&list).expect("Could not format JSON") - ); - } else { - let mut wrapper = TextWrapper::default(); + match preset { + PresetFile::Single(preset) => { + report + .items + .push(ReportItem::Child(report_preset(preset, "", &location.to_string()))); + } - println!("Plugin library tests:"); - for (test_name, test_description) in list.plugin_library_tests { - println_wrapped!(wrapper, "- {test_name}: {test_description}"); - } + PresetFile::Container(presets) => { + let mut container = Report { + header: "Preset Container".to_string(), + items: vec![], + footer: vec![], + }; + + container.items.push(ReportItem::Text(location.to_string())); + + for (key, preset) in presets { + container.items.push(ReportItem::Child(report_preset(preset, key, ""))); + } + + container.footer.push(pluralize(presets.len(), "preset")); + report.items.push(ReportItem::Child(container)); + } + } + } + + group.items.push(ReportItem::Child(report)); + } - println!("\nPlugin tests:"); - for (test_name, test_description) in list.plugin_tests { - println_wrapped!(wrapper, "- {test_name}: {test_description}"); + println!("\n{}", group); } } - - Ok(ExitCode::SUCCESS) } diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 9b71675..1660ada 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -1,143 +1,166 @@ //! Commands for validating plugins. -use std::process::ExitCode; - +use crate::cli::{Config, Report, ReportItem, pluralize}; +use crate::tests::{TestGroup, TestResult, TestStatus}; +use crate::validator::{ValidationResult, ValidationTally}; +use crate::{Verbosity, validator}; use anyhow::{Context, Result}; -use colored::Colorize; - -use super::{println_wrapped, TextWrapper}; -use crate::tests::TestStatus; -use crate::validator::{self, SingleTestSettings, ValidatorSettings}; -use crate::Verbosity; +use clap::Args; +use std::path::PathBuf; +use std::process::ExitCode; +use yansi::Paint; + +/// Options for the validator. +#[derive(Debug, Args)] +pub struct ValidatorSettings { + /// Paths to one or more plugins that should be validated. + #[arg(required = true)] + pub paths: Vec, + /// Only validate plugins with this ID. + /// + /// If the plugin library contains multiple plugins, then you can pass a single plugin's ID + /// to this option to only validate that plugin. Otherwise all plugins in the library are + /// validated. + #[arg(short = 'p', long)] + pub plugin_id: Option, + /// Print the test output as JSON instead of human readable text. + #[arg(long)] + pub json: bool, + /// Only run the tests that match this case-insensitive regular expression. + /// Multiple include patterns can be passed, in which case a test only needs to match one of them to be included. + #[arg(short = 't', long)] + pub include: Vec, + /// Don't run the tests that match this case-insensitive regular expression. + /// Exclude takes precedence over include, so if a test matches both, it will be excluded. + #[arg(short = 'x', long)] + pub exclude: Vec, + /// When running the validation out-of-process, hide the plugin's output. + /// + /// This can be useful for validating noisy plugins. + #[arg(long, conflicts_with = "in_process")] + pub hide_output: bool, + /// Only show failed tests. + /// + /// This affects both the human readable and the JSON output. + #[arg(long)] + pub only_failed: bool, + /// Run the tests within this process. + /// + /// Tests are normally run in separate processes in case the plugin crashes. Another benefit + /// of the out-of-process validation is that the test always starts from a clean state. + /// Using this option will remove those protections, but in turn the tests may run faster. + #[arg(long)] + pub in_process: bool, + /// Set the amount of parallelism when running the tests. Only for out-of-process tests. + #[arg(long, short = 'j', conflicts_with = "in_process")] + pub jobs: Option, + /// When running the validation in-process, emit a JSON trace file that can be viewed with + /// Chrome's tracing viewer or . + /// + /// This has a non-negligible performance impact. + #[arg(long, requires = "in_process")] + pub trace: bool, +} /// The main validator command. This will validate one or more plugins and print the results. -pub fn validate(verbosity: Verbosity, settings: &ValidatorSettings) -> Result { - let mut result = - validator::validate(verbosity, settings).context("Could not run the validator")?; +pub fn validate(verbosity: Verbosity, settings: ValidatorSettings) -> Result { + let config = Config::from_current()?; + + let mut result = validator::validate(verbosity, &settings, &config).context("Could not run the validator")?; let tally = result.tally(); - // Filtering out tests should be done after we did the tally for consistency's sake if settings.only_failed { - // The `.drain_filter()` methods have not been stabilized yet, so to make things - // easy for us we'll just inefficiently rebuild the data structures - result.plugin_library_tests = result - .plugin_library_tests - .into_iter() - .filter_map(|(library_path, tests)| { - let tests: Vec<_> = tests - .into_iter() - .filter(|test| test.status.failed_or_warning()) - .collect(); - if tests.is_empty() { - None - } else { - Some((library_path, tests)) - } - }) - .collect(); - - result.plugin_tests = result - .plugin_tests - .into_iter() - .filter_map(|(plugin_id, tests)| { - let tests: Vec<_> = tests - .into_iter() - .filter(|test| test.status.failed_or_warning()) - .collect(); - if tests.is_empty() { - None - } else { - Some((plugin_id, tests)) - } - }) - .collect(); + result = result.filter(|test| test.status.failed_or_warning()); } if settings.json { - println!( - "{}", - serde_json::to_string_pretty(&result).expect("Could not format JSON") - ); + println!("{}", serde_json::to_string_pretty(&result)?); } else { - let mut wrapper = TextWrapper::default(); - // This doesn't need to be a macro but the alternatives are to either wrap `wrapper` in a - // refcell or to inline this, so this is probably still better - macro_rules! print_test { - ($test:expr) => { - println_wrapped!(wrapper, " - {}: {}", $test.name, $test.description); - - let status_text = match $test.status { - TestStatus::Success { .. } => "PASSED".green(), - TestStatus::Crashed { .. } => "CRASHED".red().bold(), - TestStatus::Failed { .. } => "FAILED".red(), - TestStatus::Skipped { .. } => "SKIPPED".yellow(), - TestStatus::Warning { .. } => "WARNING".yellow(), - }; - let test_result = match $test.status.details() { - Some(reason) => format!(" {status_text}: {reason}"), - None => format!(" {status_text}"), - }; - wrapper.print_auto(test_result); - }; - } + pretty_print(&result, &tally); + } - if !result.plugin_library_tests.is_empty() { - println!("Plugin library tests:"); - for (library_path, tests) in result.plugin_library_tests { - println!(); - println_wrapped!(wrapper, " - {}", library_path.display()); + // If any of the tests failed, this process should exit with a failure code + if tally.num_failed == 0 { + Ok(ExitCode::SUCCESS) + } else { + Ok(ExitCode::FAILURE) + } +} - for test in tests { - println!(); - print_test!(test); - } - } +fn pretty_print(result: &ValidationResult, tally: &ValidationTally) { + fn report_test(result: &TestResult) -> Report { + let status_text = match result.status { + TestStatus::Success { .. } => "PASSED".green(), + TestStatus::Skipped { .. } => "SKIPPED".dim(), + TestStatus::Warning { .. } => "WARNING".yellow(), + TestStatus::Failed { .. } => "FAILED".red(), + TestStatus::Crashed { .. } => "CRASHED".red().bold(), + }; + + let mut items = vec![ReportItem::Text(result.test.description())]; + + if let Some(details) = result.status.details() { + items.push(ReportItem::Child(Report { + header: "".to_string(), + footer: vec![], + items: vec![ReportItem::Text(details.to_string())], + })); + } - println!(); + Report { + items, + header: result.test.name(), + footer: vec![ + status_text.to_string(), + format!("{}ms", result.duration.as_millis()).dim().to_string(), + ], } + } - if !result.plugin_tests.is_empty() { - println!("Plugin tests:"); - for (plugin_id, tests) in result.plugin_tests { - println!(); - println_wrapped!(wrapper, " - {plugin_id}"); + for (group, tests) in result.group() { + match group { + TestGroup::PluginLibrary(library_path) => { + let mut items = vec![ReportItem::Text(library_path.to_string_lossy().to_string())]; - for test in tests { - println!(); - print_test!(test); + for test in &tests { + items.push(ReportItem::Child(report_test(test))); } + + println!( + "\n{}", + Report { + header: "Plugin Library".to_string(), + footer: vec![pluralize(tests.len(), "test")], + items, + } + ); } - println!(); - } + TestGroup::PluginInstance(_, plugin_id) => { + let mut items = vec![ReportItem::Text(plugin_id.clone())]; - let num_tests = tally.total(); - println_wrapped!( - wrapper, - "{} {} run, {} passed, {} failed, {} skipped, {} warnings", - num_tests, - if num_tests == 1 { "test" } else { "tests" }, - tally.num_passed, - tally.num_failed, - tally.num_skipped, - tally.num_warnings - ); - } + for test in &tests { + items.push(ReportItem::Child(report_test(test))); + } - // If any of the tests failed, this process should exit with a failure code - if tally.num_failed == 0 { - Ok(ExitCode::SUCCESS) - } else { - Ok(ExitCode::FAILURE) + println!( + "\n{}", + Report { + header: "Plugin".to_string(), + footer: vec![pluralize(tests.len(), "test")], + items, + } + ); + } + } } -} -/// Run a single test and write the output to a file. This command is a hidden implementation detail -/// used by the validator to run tests in a different process. -pub fn run_single(settings: &SingleTestSettings) -> Result { - // The result will be serialized as JSON and written to a file so the main validator process can - // read it - validator::run_single_test(settings) - .map(|()| ExitCode::SUCCESS) - .context("Could not run test the case") + println!( + "{} run, {} passed, {} failed, {} warnings, {} skipped", + pluralize(tally.total(), "test"), + tally.num_passed.green().bold(), + tally.num_failed.red().bold(), + tally.num_warnings.yellow().bold(), + tally.num_skipped.bold(), + ); } diff --git a/src/fuzz.rs b/src/fuzz.rs new file mode 100644 index 0000000..a102017 --- /dev/null +++ b/src/fuzz.rs @@ -0,0 +1,180 @@ +mod rng; +mod runner; + +use crate::cli::sandbox::{SandboxConfig, SandboxOperation}; +use crate::cli::{IteratorExt, panic_message}; +use crate::commands::Verbosity; +use crate::commands::fuzz::FuzzSettings; +use anyhow::{Context, Result}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "type")] +pub enum FuzzStatus { + Success, + Failed { details: String }, + Crashed { details: String }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct FuzzResult { + pub status: FuzzStatus, + pub library: PathBuf, + pub plugin_id: String, + pub seed: u64, +} + +impl FuzzStatus { + pub fn details(&self) -> Option<&str> { + match self { + FuzzStatus::Success => None, + FuzzStatus::Failed { details } | FuzzStatus::Crashed { details } => Some(details), + } + } +} + +pub fn fuzz(verbosity: Verbosity, settings: &FuzzSettings) -> Result> { + let plugins = discover(&settings.paths, settings.plugin_id.as_deref())?; + if plugins.is_empty() { + anyhow::bail!("No plugins selected"); + } + + if let Some(seed) = settings.reproduce { + if plugins.len() > 1 { + let plugins = plugins + .iter() + .map(|(library, plugin_id)| format!("\n - {} ({})", library.display(), plugin_id)) + .collect::>() + .join(""); + + anyhow::bail!("Choose one out of: {}", plugins); + } + + let (library, plugin_id) = &plugins[0]; + let status = SandboxedFuzzChunk { + library: library.clone(), + plugin_id: plugin_id.clone(), + seed, + } + .run(); + + return Ok(vec![FuzzResult { + status, + library: library.clone(), + plugin_id: plugin_id.clone(), + seed, + }]); + } + + // round robin over the plugins until we run out of time + let start = Instant::now(); + let running = AtomicBool::new(true); + let mut results = vec![]; + let mut prng = rng::new_orchestrator_prng(); + + std::iter::repeat(&plugins) + .flatten() + .map(|(library, plugin_id)| (library, plugin_id, prng.next_u64())) + .take_while(|_| settings.duration.is_none_or(|duration| start.elapsed() < duration)) // run while we have time + .take_while(|_| running.load(Ordering::Relaxed)) // stop if we found a result + .parallel_fork_join( + settings.jobs, + |(library, plugin_id, seed)| { + let status = SandboxedFuzzChunk { + library: library.clone(), + plugin_id: plugin_id.clone(), + seed, + } + .run_sandboxed(SandboxConfig { + verbosity, + hide_output: false, + timeout: Some(std::time::Duration::from_secs(60)), + }) + .unwrap_or_else(|err| FuzzStatus::Crashed { + details: err.to_string(), + }); + + FuzzResult { + status, + library: library.clone(), + plugin_id: plugin_id.clone(), + seed, + } + }, + |chunk| { + if chunk.status != FuzzStatus::Success { + log::error!( + "{} ({}, seed {})", + chunk.status.details().unwrap_or_default(), + chunk.plugin_id, + chunk.seed, + ); + + results.push(chunk); + + if results.len() >= settings.limit { + running.store(false, Ordering::Relaxed); + } + } else { + log::debug!("OK '{}' (seed {})", chunk.plugin_id, chunk.seed); + } + }, + ); + + Ok(results) +} + +/// Scan the paths for plugins and return the paths and plugin IDs of the plugins that should be fuzzed. +fn discover(paths: &[PathBuf], plugin_id: Option<&str>) -> Result> { + let mut result = Vec::new(); + + for path in paths { + let library = crate::plugin::library::PluginLibrary::load(path)?; + + let metadata = library + .metadata() + .with_context(|| format!("Could not get the plugin metadata for library '{}'", path.display()))?; + + for plugin in metadata.plugins { + if plugin_id.as_ref().is_none_or(|id| id == &plugin.id) { + result.push((path.clone(), plugin.id)); + } + } + } + + Ok(result) +} + +#[derive(Serialize, Deserialize)] +pub struct SandboxedFuzzChunk { + library: PathBuf, + plugin_id: String, + seed: u64, +} + +impl SandboxOperation for SandboxedFuzzChunk { + const ID: &'static str = "fuzz"; + type Result = FuzzStatus; + + fn run(&self) -> Self::Result { + match catch_unwind(AssertUnwindSafe(|| { + runner::run_fuzzer(&self.library, &self.plugin_id, self.seed) + })) { + Ok(Ok(result)) => result, + Ok(Err(err)) => { + let err = err.chain().map(|x| x.to_string()).collect::>().join("\n"); + FuzzStatus::Failed { details: err } + } + Err(panic) => FuzzStatus::Crashed { + details: panic_message(&*panic), + }, + } + } +} diff --git a/src/fuzz/rng.rs b/src/fuzz/rng.rs new file mode 100644 index 0000000..38785d1 --- /dev/null +++ b/src/fuzz/rng.rs @@ -0,0 +1,248 @@ +use crate::plugin::process::{AudioBuffers, ConstantMask}; +use either::Either; +use rand::seq::IndexedRandom; +use rand::{Rng, RngExt, SeedableRng}; +use std::f64::consts::TAU; + +/// Creates a new PRNG that is seeded with the current time. +/// +/// Used for generating the seeds for child PRNGs +pub fn new_orchestrator_prng() -> rand::rngs::Xoshiro128PlusPlus { + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + rand::rngs::Xoshiro128PlusPlus::from_seed(time.to_le_bytes()) +} + +pub fn random_sample_rate(rng: &mut impl Rng) -> f64 { + const PRESET: &[f64] = &[8000.0, 11025.0, 22050.0, 44100.0, 48000.0, 96000.0, 192000.0, 384000.0]; + + if rng.random_bool(0.1) { + rng.random_range(1000.0..200000.0) + } else { + *PRESET.choose(rng).unwrap() + } +} + +pub fn random_buffer_size_range(rng: &mut impl Rng) -> (u32, u32) { + let max_buffer_size = { + const PRESET: &[u32] = &[64, 128, 256, 512, 1024, 2048, 4096, 16384]; + + if rng.random_bool(0.1) { + rng.random_range(1..10000) + } else { + *PRESET.choose(rng).unwrap() + } + }; + + let min_buffer_size = if rng.random_bool(0.25) { + max_buffer_size + } else if rng.random_bool(0.25) { + 1 + } else { + rng.random_range(1..=max_buffer_size) + }; + + (min_buffer_size, max_buffer_size) +} + +pub struct AudioFuzzer { + generators: Vec>, + change_probability: f64, +} + +impl AudioFuzzer { + pub fn new() -> Self { + Self { + generators: vec![], + change_probability: 0.1, + } + } + + pub fn fill(&mut self, rng: &mut impl Rng, sample_rate: f64, buffers: &mut AudioBuffers) { + for buffer in buffers.iter_mut() { + let Some(input) = buffer.port().input() else { continue }; + + if self.generators.len() <= input { + self.generators.resize_with(input + 1, Vec::new); + } + + let signals = &mut self.generators[input]; + if signals.len() < buffer.channels() as usize { + signals.resize_with(buffer.channels() as usize, || AudioSignal::rng(rng, sample_rate)); + } + + let mut constant_mask = ConstantMask::DYNAMIC; + for (channel, generator) in signals.iter_mut().enumerate() { + if rng.random_bool(self.change_probability) { + *generator = AudioSignal::rng(rng, sample_rate); + } + + generator.fill(rng, sample_rate, buffer.channel_mut(channel as u32)); + + if generator.is_constant() { + constant_mask = constant_mask.with_channel_constant(channel as u32); + } + } + + buffer.set_input_constant_mask(constant_mask); + } + } +} + +pub enum AudioSignal { + Sine { + phase: f64, + freq: f64, + freq_ramp: f64, + gain: f64, + gain_ramp: f64, + }, + + Noise { + gain: f64, + gain_ramp: f64, + }, + + Constant { + value: f64, + }, + + Denormal, +} + +impl AudioSignal { + pub fn rng(rng: &mut impl Rng, sample_rate: f64) -> Self { + match rng.random_range(0..7) { + // sine at nyquist + 0 => Self::Sine { + phase: 0.5, + freq: sample_rate / 2.0, + freq_ramp: 0.0, + gain: 0.0, + gain_ramp: rng.random_range(-10.0..10.0), + }, + + // sine at near dc + 1 => Self::Sine { + phase: 0.5, + freq: 1.0, + freq_ramp: 0.0, + gain: 0.0, + gain_ramp: rng.random_range(-10.0..10.0), + }, + + // random sine sweep + 2 => Self::Sine { + phase: rng.random_range(0.0..1.0), + freq: rng.random_range(20.0..20000.0), + freq_ramp: rng.random_range(-1000.0..1000.0), + gain: rng.random_range(-80.0..20.0), + gain_ramp: rng.random_range(-10.0..10.0), + }, + + // random noise ramp + 3 => Self::Noise { + gain: rng.random_range(-80.0..20.0), + gain_ramp: rng.random_range(-10.0..10.0), + }, + + // constant signal between -1 and 1 + 4 => Self::Constant { + value: rng.random_range(-1.0..1.0), + }, + + // constant signal (silent) + 5 => Self::Constant { value: 0.0 }, + + _ => Self::Denormal, + } + } + + pub fn fill(&mut self, rng: &mut impl Rng, sample_rate: f64, buffer: Either<&mut [f32], &mut [f64]>) { + fn db_to_gain(db: f64) -> f64 { + const MAX_GAIN_DB: f64 = 40.0; + 10f64.powf(db.min(MAX_GAIN_DB) / 20.0) + } + + let sample_rate_inv = 1.0 / sample_rate; + + match self { + AudioSignal::Sine { + phase, + freq, + freq_ramp, + gain, + gain_ramp, + } => match buffer { + Either::Left(buf) => { + for sample in buf { + *freq += *freq_ramp * sample_rate_inv; + *gain += *gain_ramp * sample_rate_inv; + *phase = (*phase + *freq * sample_rate_inv).rem_euclid(1.0); + *sample = (*phase * TAU).sin() as f32 * db_to_gain(*gain) as f32; + } + } + + Either::Right(buf) => { + for sample in buf { + *freq += *freq_ramp * sample_rate_inv; + *gain += *gain_ramp * sample_rate_inv; + *phase = (*phase + *freq * sample_rate_inv).rem_euclid(1.0); + *sample = (*phase * TAU).sin() * db_to_gain(*gain); + } + } + }, + + AudioSignal::Noise { gain, gain_ramp } => match buffer { + Either::Left(buf) => { + for sample in buf { + *gain += *gain_ramp * sample_rate_inv; + *sample = rng.random_range(-1.0..1.0) * db_to_gain(*gain) as f32; + } + } + + Either::Right(buf) => { + for sample in buf { + *gain += *gain_ramp * sample_rate_inv; + *sample = rng.random_range(-1.0..1.0) * db_to_gain(*gain); + } + } + }, + + AudioSignal::Constant { value } => match buffer { + Either::Left(buf) => { + for sample in buf { + *sample = *value as f32; + } + } + + Either::Right(buf) => { + for sample in buf { + *sample = *value; + } + } + }, + + AudioSignal::Denormal => match buffer { + Either::Left(buf) => { + for sample in buf { + *sample = rng.random_range(-f32::MIN_POSITIVE..f32::MIN_POSITIVE); + } + } + + Either::Right(buf) => { + for sample in buf { + *sample = rng.random_range(-f64::MIN_POSITIVE..f64::MIN_POSITIVE); + } + } + }, + } + } + + pub fn is_constant(&self) -> bool { + matches!(self, Self::Constant { .. }) + } +} diff --git a/src/fuzz/runner.rs b/src/fuzz/runner.rs new file mode 100644 index 0000000..2677d54 --- /dev/null +++ b/src/fuzz/runner.rs @@ -0,0 +1,557 @@ +use crate::cli::tracing::{Span, record}; +use crate::fuzz::FuzzStatus; +use crate::fuzz::rng::{AudioFuzzer, random_buffer_size_range, random_sample_rate}; +use crate::plugin::ext::audio_ports::AudioPorts; +use crate::plugin::ext::audio_ports_activation::{AudioPortsActivation, AudioPortsActivationAudio}; +use crate::plugin::ext::audio_ports_config::AudioPortsConfig; +use crate::plugin::ext::configurable_audio_ports::ConfigurableAudioPorts; +use crate::plugin::ext::note_ports::NotePorts; +use crate::plugin::ext::params::{Params, ParamsRescan}; +use crate::plugin::ext::render::{Render, RenderMode}; +use crate::plugin::ext::state::State; +use crate::plugin::ext::voice_info::VoiceInfo; +use crate::plugin::instance::{CallbackEvent, HostCapabilities, Plugin}; +use crate::plugin::library::PluginLibrary; +use crate::plugin::process::{AudioBuffers, Event, InputEventQueue, OutputEventQueue, ProcessScope}; +use crate::tests::rng::{NoteGenerator, ParamFuzzer, TransportFuzzer, random_layout_requests}; +use anyhow::Result; +use clap_sys::ext::voice_info::CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES; +use rand::rngs::Xoshiro128PlusPlus; +use rand::seq::{IndexedRandom, IteratorRandom}; +use rand::{Rng, RngExt, SeedableRng}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// Runs a single fuzzer chunk for a given plugin. +/// +/// Fully deterministic w.r.t. the seed. +/// +/// The fuzzer: +/// - Randomly generates some processing configurations (buffer size, sample rate, etc.) +pub fn run_fuzzer(library: &Path, plugin_id: &str, seed: u64) -> Result { + let _span = Span::begin( + "Fuzzer", + record! { + library: library.display().to_string(), + plugin_id: plugin_id.to_string(), + seed: seed + }, + ); + + let mut prng = Xoshiro128PlusPlus::seed_from_u64(seed); + let has_tail_extension = prng.random_bool(0.9); + let has_latency_extension = prng.random_bool(0.9); + let has_state_extension = prng.random_bool(0.9); + let has_params_extension = prng.random_bool(0.9); + let supports_clap_dialect = prng.random_bool(0.9); + let supports_midi_dialect = prng.random_bool(0.9); + let can_rescan_audio_ports = prng.random_bool(0.9); + + let library = PluginLibrary::load(library)?; + let plugin = library.create_plugin_with( + plugin_id, + HostCapabilities { + has_tail_extension, + has_latency_extension, + has_state_extension, + has_params_extension, + supports_clap_dialect, + supports_midi_dialect, + can_rescan_audio_ports, + ..Default::default() + }, + )?; + + plugin.init()?; + + let mut audio_config = plugin + .get_extension::() + .map(|audio_ports| audio_ports.config()) + .transpose()? + .unwrap_or_default(); + + let mut note_config = plugin + .get_extension::() + .map(|note_ports| note_ports.config()) + .transpose()? + .unwrap_or_default(); + + let mut param_info = if has_params_extension { + plugin + .get_extension::() + .map(|params| params.info()) + .transpose()? + .unwrap_or_default() + } else { + Default::default() + }; + + // randomize parameters and set via flush + if !param_info.is_empty() { + let param_fuzzer = ParamFuzzer::new(¶m_info); + let input_queue = InputEventQueue::new(); + let output_queue = OutputEventQueue::new(); + + input_queue.add_events(param_fuzzer.randomize_params_at(&mut prng, 0)); + plugin + .get_extension::() + .ok_or(anyhow::anyhow!( + "No 'params' extension when querying it for a second time" + ))? + .flush(&input_queue, &output_queue); + } + + // audio ports activation + let ext_activation = plugin.get_extension::(); + let can_activate_while_processing = ext_activation + .as_ref() + .map(|ext| ext.can_activate_while_processing()) + .unwrap_or(false); + + // we use this to check state saving/loading (in parallel) + let last_saved_state = Arc::new(Mutex::new(None)); + + for _ in 0..5 { + if choose_random_layout(&plugin, &mut prng)? { + // If we successfully chose a layout, the port configuration might have changed, so we re-query it here to be sure. + audio_config = plugin + .get_extension::() + .map(|ports| ports.config()) + .transpose()? + .unwrap_or_default(); + } + + // use in-place processing if possible + let is_in_place = prng.random_bool(0.5); + // use 64-bit processing if possible + let is_64bit = prng.random_bool(0.5); + + // new random sample rate (use preferined for 90% of the cases, fully random for 10% of the cases) + let sample_rate = random_sample_rate(&mut prng); + let (min_buffer_size, max_buffer_size) = random_buffer_size_range(&mut prng); + + // a quiet section is where we send no events and just process silent audio (used for tail and silence checks) + let mut is_quiet = false; + let mut blocks_to_process = prng.random_range(20000..200000u32).div_ceil(max_buffer_size); + let mut input_ports_active = vec![true; audio_config.inputs.len()]; + let mut output_ports_active = vec![true; audio_config.outputs.len()]; + + if let Some(ext) = plugin.get_extension::() { + for i in 0..audio_config.inputs.len() { + input_ports_active[i] = prng.random_bool(0.5); + ext.set_active(true, i as u32, input_ports_active[i], 0); + } + + for i in 0..audio_config.outputs.len() { + output_ports_active[i] = prng.random_bool(0.5); + ext.set_active(false, i as u32, output_ports_active[i], 0); + } + } + + if let Some(ext) = plugin.get_extension::() + && prng.random_bool(0.1) + { + let mode = match prng.random_bool(0.5) { + true => RenderMode::Offline, + false => RenderMode::Realtime, + }; + + ext.set(mode); + } + + let _span = Span::begin( + "FuzzerConfig", + record! { + sample_rate: sample_rate, + blocks_to_process: blocks_to_process, + min_buffer_size: min_buffer_size, + max_buffer_size: max_buffer_size, + is_in_place: is_in_place, + is_64bit: is_64bit + }, + ); + + while blocks_to_process > 0 { + let mut audio_config_changed = false; + let mut note_config_changed = false; + let mut params_changed = false; + + plugin.poll_callback(|event| { + match event { + CallbackEvent::AudioPortsRescanList + | CallbackEvent::AudioPortsRescanInfo + | CallbackEvent::AudioPortsConfigRescan => { + audio_config_changed = true; + } + CallbackEvent::ParamsRescan(ParamsRescan::All | ParamsRescan::Info) => { + params_changed = true; + } + CallbackEvent::NotePortsRescanAll => { + note_config_changed = true; + } + + _ => {} + } + + Ok(()) + })?; + + if audio_config_changed { + audio_config = plugin + .get_extension::() + .map(|ports| ports.config()) + .transpose()? + .unwrap_or_default(); + + // this invalidates audio activation state, so we have to reset it + input_ports_active = vec![true; audio_config.inputs.len()]; + output_ports_active = vec![true; audio_config.outputs.len()]; + + if let Some(ext) = plugin.get_extension::() { + for i in 0..audio_config.inputs.len() { + input_ports_active[i] = prng.random_bool(0.5); + ext.set_active(true, i as u32, input_ports_active[i], 0); + } + + for i in 0..audio_config.outputs.len() { + output_ports_active[i] = prng.random_bool(0.5); + ext.set_active(false, i as u32, output_ports_active[i], 0); + } + } + } + + if note_config_changed { + note_config = plugin + .get_extension::() + .map(|ports| ports.config()) + .transpose()? + .unwrap_or_default(); + } + + if params_changed { + param_info = plugin + .get_extension::() + .map(|params| params.info()) + .transpose()? + .unwrap_or_default(); + } + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut buffers = match (is_in_place, is_64bit) { + (true, true) => AudioBuffers::new_in_place_f64(&audio_config, max_buffer_size)?, + (true, false) => AudioBuffers::new_in_place_f32(&audio_config, max_buffer_size)?, + (false, true) => AudioBuffers::new_out_of_place_f64(&audio_config, max_buffer_size), + (false, false) => AudioBuffers::new_out_of_place_f32(&audio_config, max_buffer_size), + }; + + let mut process = ProcessScope::with_config(&plugin, &mut buffers, sample_rate, min_buffer_size)?; + let mut transport_fuzzer = TransportFuzzer::new(); + let mut audio_fuzzer = AudioFuzzer::new(); + let mut param_fuzzer = ParamFuzzer::new(¶m_info).with_sample_offset_range(-10..=200); + let mut event_fuzzer = NoteGenerator::new(¬e_config) + .with_wildcard_events() + .with_params(¶m_info) + .with_sample_offset_range(-10..=200); + + // voice_info::get needs to be called in an active state + process.activate()?; + + let supports_overlapping_notes = plugin.on_main_thread(|plugin| { + plugin + .get_extension::() + .and_then(|x| x.get()) + .is_some_and(|info| (info.flags & CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES) != 0) + }); + + if supports_overlapping_notes { + event_fuzzer = event_fuzzer.with_overlapping_notes(); + } + + while blocks_to_process > 0 { + if process.wants_restart() { + // exit the audio thread and do a full reinit before processing next blocks + return Ok(()); + } + + // random number of samples per block within the requested buffer size range + let num_samples = if prng.random_bool(0.5) { + prng.random_range(min_buffer_size..=max_buffer_size) + } else if prng.random_bool(0.5) { + max_buffer_size + } else { + min_buffer_size + }; + + // toggle between quiet and non-quiet sections + if prng.random_bool(0.01) { + is_quiet = !is_quiet; + } + + let _span = Span::begin("FuzzerBlock", record! { num_samples: num_samples, is_quiet: is_quiet }); + + // sometimes randomize activation flags if we can + if can_activate_while_processing && prng.random_bool(0.05) { + let Some(ext) = plugin.get_extension::() else { + anyhow::bail!( + "The plugin does not provide a valid 'audio-ports-activation' extension on subsequent \ + calls to get_extension" + ) + }; + + process.activate()?; + + for i in 0..audio_config.inputs.len() { + input_ports_active[i] = prng.random_bool(0.5); + ext.set_active(true, i as u32, input_ports_active[i], 0); + } + + for i in 0..audio_config.outputs.len() { + output_ports_active[i] = prng.random_bool(0.5); + ext.set_active(false, i as u32, output_ports_active[i], 0); + } + } + + // sometimes we do a state reset + if prng.random_bool(0.05) { + process.reset(); + } + + // sometimes we do a full restart (deactivate + activate) + if prng.random_bool(0.05) { + process.deactivate(); + } + + if is_quiet { + // if quiet, do not send any events and fill the audio inputs with silence (and set constant flags) + process.audio_buffers().fill_silence(); + } else { + // sometimes generate events with null cookies to test plugins handling of that + param_fuzzer.no_cookies = prng.random_bool(0.1); + + // add random note and modulation events if we have the input ports + process.add_events(event_fuzzer.generate_events(&mut prng, num_samples)); + + // add random parameter change events if we have parameters + process.add_events(param_fuzzer.generate_events(&mut prng, num_samples)); + + // randomize transport + process.transport().is_freerun = prng.random_bool(0.1); // null-transport + transport_fuzzer.mutate(&mut prng, process.transport()); // mutate block transport + + // sometimes add a random transport event in the middle of the block + if prng.random_bool(0.2) { + let time_offset = prng.random_range(0..num_samples); + let mut transport = process.transport().clone(); + transport.advance(time_offset as _, sample_rate); + process.add_events([Event::Transport(transport.as_clap_transport(time_offset))]); + } + + // randomize audio inputs + audio_fuzzer.fill(&mut prng, sample_rate, process.audio_buffers()); + + // fill inputs that correspond to inactive ports with silence + for port in process.audio_buffers().iter_mut() { + if let Some(input) = port.port().input() + && !input_ports_active[input] + { + port.fill_silence(); + } + } + } + + // set what output ports are active and what ports are not (so we can skip checks for inactive ports) + for i in 0..audio_config.outputs.len() { + process.set_output_active(i as u32, output_ports_active[i]); + } + + // try saving the current state in parallel + if prng.random_bool(0.01) && has_state_extension { + let last_saved_state = last_saved_state.clone(); + let buffer_size = match prng.random_bool(0.5) { + true => Some(prng.random_range(1..=64)), + false => None, + }; + + plugin.send_main_thread(move |plugin| { + let state = match plugin.get_extension::() { + Some(state) => state, + None => return Ok(()), // plugin does not support state, skip + }; + + let saved_state = match buffer_size { + Some(size) => state.save_buffered(size)?, + None => state.save()?, + }; + + *last_saved_state.lock().unwrap() = Some(saved_state); + Ok(()) + }); + } + + // try loading the last saved state in parallel + if prng.random_bool(0.01) && has_state_extension { + let last_saved_state = last_saved_state.clone(); + let buffer_size = match prng.random_bool(0.5) { + true => Some(prng.random_range(1..=64)), + false => None, + }; + + plugin.send_main_thread(move |plugin| { + let state = match plugin.get_extension::() { + Some(state) => state, + None => return Ok(()), // plugin does not support state, skip + }; + + let Some(last_saved_state) = last_saved_state.lock().unwrap().clone() else { + return Ok(()); // no state saved yet, skip + }; + + match buffer_size { + Some(size) => state.load_buffered(&last_saved_state, size)?, + None => state.load(&last_saved_state)?, + }; + + Ok(()) + }); + } + + // try a random value to text to value to text roundtrip conversion + if prng.random_bool(0.05) { + // choose a random parameter and a random value and do a roundtrip conversion (value -> text -> value -> text) on the main thread _in parallel_. + if let Some((&id, param)) = param_info.iter().choose(&mut prng) { + let value = ParamFuzzer::random_value(param, &mut prng); + plugin.send_main_thread(move |plugin| test_value_conversion(plugin, id, value)); + } + } + + // try a random render mode (set in parallel) + if prng.random_bool(0.01) { + let mode = match prng.random_bool(0.5) { + true => RenderMode::Offline, + false => RenderMode::Realtime, + }; + + plugin.send_main_thread(move |plugin| { + let Some(render) = plugin.get_extension::() else { + return Ok(()); + }; + + render.set(mode); + Ok(()) + }); + } + + // unsynchronized poll, runs parallel to the audio thread (non-blocking) + if prng.random_bool(0.8) { + plugin.poll_callback(); + } + + // do the process!! + process.run_with(num_samples)?; + blocks_to_process -= 1; + + //TODO: post process validation + } + + Ok(()) + })?; + } + } + + Ok(FuzzStatus::Success) +} + +fn choose_random_layout(plugin: &Plugin, rng: &mut impl Rng) -> Result { + if rng.random_bool(0.25) + && let Some(ext) = plugin.get_extension::() + { + let list = ext.enumerate()?; + if list.is_empty() { + return Ok(false); + } + + let config = list.choose(rng).unwrap(); + ext.select(config.id)?; + return Ok(true); + } + + if rng.random_bool(0.25) + && let Some(ext) = plugin.get_extension::() + { + let config = match plugin.get_extension::() { + Some(ports) => ports.config()?, + None => return Ok(false), + }; + + // 100 attempts + for _ in 0..100 { + let layout = random_layout_requests(&config, rng); + if ext.can_apply_configuration(&layout) { + if ext.apply_configuration(&layout) { + return Ok(true); + } else { + anyhow::bail!( + "'clap_plugin_configurable_audio_ports::apply_configuration' returned false but \ + 'can_apply_configuration' returned true." + ) + } + } + } + } + + Ok(false) +} + +fn test_value_conversion(plugin: &Plugin, param_id: u32, value: f64) -> Result<()> { + let params = match plugin.get_extension::() { + Some(params) => params, + None => anyhow::bail!("Plugin does not support 'Params' extension"), + }; + + let text_first = match params.value_to_text(param_id, value)? { + Some(text) => text, + None => return Ok(()), // this parameter does not support v2t, skip + }; + + let value_second = match params.text_to_value(param_id, &text_first)? { + Some(value) => value, + None => { + log::warn!( + "Text conversion error for parameter {}: {} -> '{}' -> ?", + param_id, + value, + text_first + ); + + return Ok(()); + } + }; + + let text_second = match params.value_to_text(param_id, value_second)? { + Some(text) => text, + None => { + log::warn!( + "Text conversion error for parameter {}: {} -> '{}' -> {} -> ?", + param_id, + value, + text_first, + value_second + ); + + return Ok(()); + } + }; + + if text_first != text_second { + log::warn!( + "Text conversion error for parameter {}: {} -> {:?} -> {} -> {:?}", + param_id, + value, + text_first, + value_second, + text_second + ); + } + + Ok(()) +} diff --git a/src/index.rs b/src/index.rs deleted file mode 100644 index 277a406..0000000 --- a/src/index.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Utilities and data structures for indexing plugins and presets. - -use anyhow::{Context, Result}; -use serde::Serialize; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use walkdir::{DirEntry, WalkDir}; - -use crate::plugin::library::{PluginLibrary, PluginLibraryMetadata}; -use crate::plugin::preset_discovery::{LocationValue, PresetFile, Soundpack}; - -/// The separator for path environment variables. -#[cfg(unix)] -const PATH_SEPARATOR: char = ':'; -/// The separator for path environment variables. -#[cfg(windows)] -const PATH_SEPARATOR: char = ';'; - -/// A map containing metadata for all CLAP plugins found on this system. Each plugin path in the map -/// contains zero or more plugins. See [`index()`]. -/// -/// Uses a `BTreeMap` purely so the order is stable. -#[derive(Debug, Serialize)] -pub struct Index(pub BTreeMap); - -/// Build an index of all CLAP plugins on this system. This finds all `.clap` files as specified in -/// [entry.h](https://github.com/free-audio/clap/blob/main/include/clap/entry.h), and lists all -/// plugins contained within those files. If a `.clap` file was found during the scan that could not -/// be read, then a warning will be printed. -pub fn index() -> Index { - let mut index = Index(BTreeMap::new()); - let directories = match clap_directories() { - Ok(directories) => directories, - Err(err) => { - log::error!("Could not find the CLAP plugin locations: {err:#}"); - return index; - } - }; - - for directory in directories { - for clap_plugin_path in walk_clap_plugins(&directory) { - let metadata = PluginLibrary::load(clap_plugin_path.path()) - .with_context(|| format!("Could not load '{}'", clap_plugin_path.path().display())) - .and_then(|plugin| { - plugin.metadata().with_context(|| { - format!( - "Could not fetch plugin metadata for '{}'", - clap_plugin_path.path().display() - ) - }) - }); - - match metadata { - Ok(metadata) => { - index.0.insert(clap_plugin_path.into_path(), metadata); - } - Err(err) => log::error!("{err:#}"), - } - } - } - - index -} - -/// A map containing metadata for all presets supported by a set of `.clap` plugin library files. -/// When crawling all installed plugins this will only contain entries for plugins that support -/// preset discovery. -/// -/// Uses a `BTreeMap` purely so the order is stable. -#[derive(Debug, Default, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct PresetIndex(pub BTreeMap); - -/// A result-like enum for the index. `anyhow::Result` cannot be serialized. -#[derive(Debug, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum PresetIndexResult { - Success(Vec), - Error(String), -} - -/// Preset information declared by a preset provider. -#[derive(Debug, Serialize, Default)] -#[serde(rename_all = "kebab-case")] -pub struct ProviderPresets { - /// The preset provider's name. - pub provider_name: String, - /// The preset provider's vendor. - pub provider_vendor: Option, - // All sound packs declared by the plugin. - pub soundpacks: Vec, - // All presets declared by the plugin, indexed by their location. Represented by a tuple list - // because JSON object keys must be strings, and with the change from URIs to a location - // kind+value that's not longer the case. - #[serde(with = "serde_with::rust::btreemap_as_tuple_list")] - pub presets: BTreeMap, -} - -/// Index the presets for one or more plugins. [`index()`] can be used to build a list of all -/// installed CLAP plugins. Plugins that -pub fn index_presets(plugin_paths: I, skip_unsupported: bool) -> Result -where - I: IntoIterator, - P: AsRef, -{ - let mut index = PresetIndex::default(); - - for path in plugin_paths { - let path = path.as_ref(); - let library = crate::plugin::library::PluginLibrary::load(path) - .with_context(|| format!("Could not load '{}'", path.display()))?; - - let preset_discovery_factory = library.preset_discovery_factory().with_context(|| { - format!( - "Could not get the preset discovery factory for '{}", - path.display() - ) - }); - if preset_discovery_factory.is_err() && skip_unsupported { - continue; - } - - let result = preset_discovery_factory.and_then(|factory| { - let metadata = factory - .metadata() - .context("Could not get the preset discovery's provider descriptors")?; - - let mut provider_results = Vec::new(); - for provider_metadata in metadata { - let provider = factory - .create_provider(&provider_metadata) - .with_context(|| { - format!( - "Could not create the provider with ID '{}'", - provider_metadata.id - ) - })?; - - let declared_data = provider.declared_data(); - let mut presets = BTreeMap::new(); - for location in &declared_data.locations { - presets.extend(provider.crawl_location(location).with_context(|| { - format!( - "Error occurred while crawling presets for the location '{}' with {} \ - using provider '{}' with ID '{}'", - location.name, - location.value, - provider_metadata.name, - provider_metadata.id, - ) - })?); - } - - provider_results.push(ProviderPresets { - provider_name: provider_metadata.name, - provider_vendor: provider_metadata.vendor, - soundpacks: declared_data.soundpacks.clone(), - presets, - }); - } - - Ok(provider_results) - }); - - match result { - Ok(provider_results) => { - index.0.insert( - path.to_owned(), - PresetIndexResult::Success(provider_results), - ); - } - Err(err) => { - index.0.insert( - path.to_owned(), - PresetIndexResult::Error(format!("{err:#}")), - ); - } - } - } - - Ok(index) -} - -/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an -/// error if the paths could not be parsed correctly. -/// -/// While not part of the specification, the Linux paths are also used on the BSDs. -#[cfg(all(target_family = "unix", not(target_os = "macos")))] -pub fn clap_directories() -> Result> { - let home_dir = std::env::var("HOME").context("'$HOME' is not set")?; - - let mut directories = clap_env_path_directories(); - directories.push(Path::new(&home_dir).join(".clap")); - directories.push(PathBuf::from("/usr/lib/clap")); - - Ok(directories) -} - -/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an -/// error if the paths could not be parsed correctly. -#[cfg(target_os = "macos")] -pub fn clap_directories() -> Result> { - let home_dir = std::env::var("HOME").context("'$HOME' is not set")?; - - let mut directories = clap_env_path_directories(); - directories.push(Path::new(&home_dir).join("Library/Audio/Plug-Ins/CLAP")); - directories.push(PathBuf::from("/Library/Audio/Plug-Ins/CLAP")); - - Ok(directories) -} - -/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an -/// error if the paths could not be parsed correctly. -#[cfg(windows)] -pub fn clap_directories() -> Result> { - let common_files = - std::env::var("COMMONPROGRAMFILES").context("'$COMMONPROGRAMFILES' is not set")?; - let local_appdata = std::env::var("LOCALAPPDATA").context("'$LOCALAPPDATA' is not set")?; - - // TODO: Does this work reliably? There are dedicated Win32 API functions for getting these - // directories, but I'd rather avoid adding a dependency just for that. - let mut directories = clap_env_path_directories(); - directories.push(Path::new(&common_files).join("CLAP")); - directories.push(Path::new(&local_appdata).join("Programs/Common/CLAP")); - - Ok(directories) -} - -/// Parse `$CLAP_PATH` by splitting on on colons. This will return an empty Vec if the environment -/// variable is not set. -fn clap_env_path_directories() -> Vec { - std::env::var("CLAP_PATH") - .map(|clap_path| clap_path.split(PATH_SEPARATOR).map(PathBuf::from).collect()) - .unwrap_or_else(|_| Vec::new()) -} - -/// Return an iterator over all `.clap` plugins under `directory`. These will be files on Linux and -/// Windows, and (bundle) directories on macOS. -fn walk_clap_plugins(directory: &Path) -> impl Iterator { - WalkDir::new(directory) - .min_depth(1) - .follow_links(true) - .same_file_system(false) - .into_iter() - .filter_map(|entry| entry.ok()) - // Only consider valid `.clap` files or bundles. We'll need to follow symlinks as part of - // that check. - .filter(|entry| match entry.file_name().to_str() { - #[cfg(not(target_os = "macos"))] - Some(file_name) => { - file_name.ends_with(".clap") - && std::fs::canonicalize(entry.path()) - .map(|path| path.is_file()) - .unwrap_or(false) - } - #[cfg(target_os = "macos")] - Some(file_name) => { - file_name.ends_with(".clap") - && std::fs::canonicalize(entry.path()) - .map(|path| path.is_dir()) - .unwrap_or(false) - } - None => false, - }) -} diff --git a/src/main.rs b/src/main.rs index 901d75d..77761c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,123 +1,91 @@ -use clap::{Parser, Subcommand, ValueEnum}; -use std::path::PathBuf; +#![allow(clippy::needless_range_loop)] + +use commands::{Arguments, Command, Verbosity}; use std::process::ExitCode; -use validator::{SingleTestSettings, ValidatorSettings}; +use yansi::Paint; +mod cli; mod commands; -mod index; +mod fuzz; mod plugin; mod tests; -mod util; mod validator; -#[derive(Parser)] -#[command(author, version, about, long_about = None, propagate_version = true)] -struct Cli { - /// clap-validator's own logging verbosity. - /// - /// This can be used to silence all non-essential output, or to enable more in depth tracing. - #[arg(short, long, default_value = "debug")] - verbosity: Verbosity, - - #[command(subcommand)] - command: Command, -} +fn main() -> ExitCode { + let args = ::parse(); -/// The verbosity level. Set to `Debug` by default. `Trace` can be used to get more information on -/// what the validator is actually doing. -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum Verbosity { - /// Suppress all logging output from the validator itself. - Quiet, - Error, - Warn, - Info, - Debug, - Trace, -} + if !matches!(args.command, Command::Sandbox(_)) { + // Before doing anything, we need to make sure any temporary artifact files from the previous + // run are cleaned up. These are used for things like state dumps when one of the state tests + // fail. This is allowed to fail since the directory may not exist and even if it does and we + // cannot remove it, then that may not be a problem. + let _ = std::fs::remove_dir_all(cli::validator_temp_dir()); + let _ = std::fs::create_dir_all(cli::validator_temp_dir()); + } -/// The validator's subcommands. -#[derive(Subcommand)] -enum Command { - /// Validate one or more plugins. - Validate(ValidatorSettings), - /// Run a single test. - /// - /// This is used for the out-of-process testing. Since it's merely an implementation detail, the - /// option is not shown in the CLI. - #[command(hide = true)] - RunSingleTest(SingleTestSettings), + // begin instrumentation if enabled + let trace_path = cli::validator_temp_dir().join("trace.json"); + let trace_enabled = match &args.command { + Command::Validate(settings) => settings.trace, + Command::Fuzz(settings) => settings.trace, + _ => false, + }; - #[command(subcommand)] - List(ListCommand), -} + if trace_enabled { + cli::tracing::install(&trace_path); + } -/// Commands for listing tests and data realted to the installed plugins. -#[derive(Subcommand)] -pub enum ListCommand { - /// Lists basic information about all installed CLAP plugins. - Plugins { - /// Print JSON instead of a human readable format. - #[arg(short, long)] - json: bool, - }, - /// Lists the available presets for one, more, or all installed CLAP plugins. - Presets { - /// Print JSON instead of a human readable format. - #[arg(short, long)] - json: bool, - /// Paths to one or more plugins that should be indexed for presets, optional. - /// - /// All installed plugins are crawled if this value is missing. - paths: Option>, - }, - /// Lists all available test cases. - Tests { - /// Print JSON instead of a human readable format. - #[arg(short, long)] - json: bool, - }, -} + // setup logging + log::set_logger(&cli::CustomLogger).unwrap(); + log::set_max_level(match args.verbosity { + Verbosity::Quiet => log::LevelFilter::Off, + Verbosity::Error => log::LevelFilter::Error, + Verbosity::Warn => log::LevelFilter::Warn, + Verbosity::Info => log::LevelFilter::Info, + Verbosity::Debug => log::LevelFilter::Debug, + Verbosity::Trace => log::LevelFilter::Trace, + }); -fn main() -> ExitCode { - let cli = Cli::parse(); + // install the panic hook to log panics instead of printing them to stderr directly + cli::install_panic_hook(); - // For now logging everything to the terminal is fine. In the future it may be useful to have - // CLI options for things like the verbosity level. - simplelog::TermLogger::init( - match cli.verbosity { - Verbosity::Quiet => simplelog::LevelFilter::Off, - Verbosity::Error => simplelog::LevelFilter::Error, - Verbosity::Warn => simplelog::LevelFilter::Warn, - Verbosity::Info => simplelog::LevelFilter::Info, - Verbosity::Debug => simplelog::LevelFilter::Debug, - Verbosity::Trace => simplelog::LevelFilter::Trace, - }, - simplelog::ConfigBuilder::new() - .set_thread_mode(simplelog::ThreadLogMode::Both) - .set_location_level(simplelog::LevelFilter::Debug) - .build(), - simplelog::TerminalMode::Stderr, - simplelog::ColorChoice::Auto, - ) - .expect("Could not initialize logger"); - log_panics::init(); + // mark the main thread as such for plugin instance creation checks + unsafe { + plugin::library::mark_current_thread_as_os_main_thread(); + } - let result = match cli.command { - Command::Validate(settings) => commands::validate::validate(cli.verbosity, &settings), - Command::RunSingleTest(settings) => commands::validate::run_single(&settings), - Command::List(ListCommand::Plugins { json }) => commands::list::plugins(json), - Command::List(ListCommand::Presets { json, paths }) => { - commands::list::presets(json, paths.as_deref()) + let result = match args.command { + Command::Validate(settings) => commands::validate::validate(args.verbosity, settings), + Command::Fuzz(settings) => commands::fuzz::fuzz(args.verbosity, settings), + Command::List(command) => commands::list::list(args.verbosity, command), + Command::Sandbox(payload) => { + payload.dispatch(); + Ok(ExitCode::SUCCESS) } - Command::List(ListCommand::Tests { json }) => commands::list::tests(json), }; - match result { - Ok(exit_code) => exit_code, + let status = match &result { + Ok(code) => *code, Err(err) => { - log::error!("{err:?}"); + eprintln!("{} {err:#}", "Error:".red().bold()); ExitCode::FAILURE } + }; + + if trace_enabled { + match cli::tracing::check_error() { + Err(e) => eprintln!("{}: {}", "Failed to write trace".red().italic(), e), + Ok(()) => eprintln!( + "{}", + format!( + "Trace written to '{}'. Go to https://ui.perfetto.dev/ to view it.", + trace_path.display() + ) + .dim() + .italic() + ), + } } + + status } diff --git a/src/plugin.rs b/src/plugin.rs index a46a364..9c76e92 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -1,62 +1,9 @@ //! Contains functions for loading and interacting with CLAP plugins. pub mod ext; -pub mod host; +pub mod index; pub mod instance; pub mod library; pub mod preset_discovery; - -/// Used for asserting that the plugin is in the correct state when calling a function. Hard panics -/// if this is not the case. This is used to ensure the validator's correctness. -/// -/// Requires a `.status()` method to exist on `$self`. -macro_rules! assert_plugin_state_eq { - ($self:expr, $expected:expr) => { - let status = $self.status(); - if status != $expected { - panic!( - "Invalid plugin function call while the plugin is in an incorrect state ({:?} != \ - {:?}). This is a bug in the validator.", - status, $expected - ) - } - }; -} - -/// Used for asserting that the plugin is a lower state then the specified one before calling a -/// function. Hard panics if this is not the case. This is used to ensure the validator's -/// correctness. -/// -/// Requires a `.status()` method to exist on `$self`. -macro_rules! assert_plugin_state_lt { - ($self:expr, $other:expr) => { - let status = $self.status(); - if status >= $other { - panic!( - "Invalid plugin function call while the plugin is in an incorrect state ({:?} >= \ - {:?}). This is a bug in the validator.", - status, $other - ) - } - }; -} - -/// Used for asserting that the plugin has been initialized. Hard panics if this is not the case. -/// This is used to ensure the validator's correctness. -/// -/// Requires a `.status()` method to exist on `$self`. -macro_rules! assert_plugin_state_initialized { - ($self:expr) => { - let status = $self.status(); - if status == PluginStatus::Uninitialized { - panic!( - "Invalid plugin function call while the plugin has not yet been initialized. This \ - is a bug in the validator." - ) - } - }; -} - -pub(crate) use assert_plugin_state_eq; -pub(crate) use assert_plugin_state_initialized; -pub(crate) use assert_plugin_state_lt; +pub mod process; +pub mod util; diff --git a/src/plugin/ext.rs b/src/plugin/ext.rs index b676f48..cee7d2a 100644 --- a/src/plugin/ext.rs +++ b/src/plugin/ext.rs @@ -5,24 +5,37 @@ use std::ffi::CStr; use std::ptr::NonNull; +pub mod ambisonic; pub mod audio_ports; +pub mod audio_ports_activation; +pub mod audio_ports_config; +pub mod configurable_audio_ports; +pub mod latency; pub mod note_ports; pub mod params; pub mod preset_load; +pub mod render; pub mod state; +pub mod surround; +pub mod tail; +pub mod thread_pool; +pub mod voice_info; -/// An abstraction for a CLAP plugin extension. `P` here is the plugin type. In practice, this is -/// either `Plugin` or `PluginAudioThread`. Abstractions for main thread functions will implement -/// this trait for `Plugin`, and abstractions for audio thread functions will implement this trait -/// for `PluginAudioThread`. -pub trait Extension

{ - /// The C-string ID for the extension. - const EXTENSION_ID: &'static CStr; +/// An abstraction for a CLAP plugin extension. +pub trait Extension { + /// The list of C-string IDs for the extension. + const IDS: &'static [&'static CStr]; + /// The plugin type (`Plugin` for main-thread, `PluginShared` for shared, `PluginAudioThread` for audio-thread) for which this extension is implemented. + type Plugin; /// The type of the C-struct for the extension. type Struct; /// Construct the extension for the plugin type `P`. This allows the abstraction to be limited /// to only work with the main thread `&Plugin` or the audio thread `&PluginAudioThread`. - fn new(plugin: P, extension_struct: NonNull) -> Self; + /// + /// # Safety + /// The extension struct pointer must be a valid pointer to the correct extension struct for + /// the plugin instance and given `IDS`. + unsafe fn new(plugin: Self::Plugin, extension_struct: NonNull) -> Self; } diff --git a/src/plugin/ext/ambisonic.rs b/src/plugin/ext/ambisonic.rs new file mode 100644 index 0000000..6d4ba81 --- /dev/null +++ b/src/plugin/ext/ambisonic.rs @@ -0,0 +1,90 @@ +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use clap_sys::ext::ambisonic::*; +use std::ffi::CStr; +use std::mem::zeroed; +use std::ptr::NonNull; + +pub struct Ambisonic<'a> { + plugin: &'a Plugin<'a>, + ambisonic: NonNull, +} + +impl<'a> Extension for Ambisonic<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_AMBISONIC, CLAP_EXT_AMBISONIC_COMPAT]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_ambisonic; + + unsafe fn new(plugin: &'a Plugin<'a>, ambisonic: NonNull) -> Self { + Self { plugin, ambisonic } + } +} + +impl<'a> Ambisonic<'a> { + pub fn is_config_supported(&self, config: &clap_ambisonic_config) -> bool { + let ambisonic = self.ambisonic.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_ambisonic::is_config_supported", config); + let result = unsafe { + clap_call! { ambisonic=>is_config_supported(plugin, config) } + }; + + span.finish(record!(result: result)); + result + } + + pub fn get_config(&self, is_input: bool, port_index: u32) -> Option { + let ambisonic = self.ambisonic.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_ambisonic::get_config", + record! { + is_input: is_input, + port_index: port_index + }, + ); + + unsafe { + let mut config = clap_ambisonic_config { ..zeroed() }; + let result = clap_call! { ambisonic=>get_config(plugin, is_input, port_index, &mut config) }; + + if result { + span.finish(record!(result: config)); + Some(config) + } else { + span.finish(record!(result: false)); + None + } + } + } +} + +impl Recordable for clap_ambisonic_config { + fn record(&self, record: &mut dyn Recorder) { + record.record( + "ordering", + match self.ordering { + CLAP_AMBISONIC_ORDERING_ACN => "CLAP_AMBISONIC_ORDERING_ACN", + CLAP_AMBISONIC_ORDERING_FUMA => "CLAP_AMBISONIC_ORDERING_FUMA", + _ => "?", + }, + ); + + record.record( + "normalization", + match self.normalization { + CLAP_AMBISONIC_NORMALIZATION_MAXN => "CLAP_AMBISONIC_NORMALIZATION_MAXN", + CLAP_AMBISONIC_NORMALIZATION_SN3D => "CLAP_AMBISONIC_NORMALIZATION_SN3D", + CLAP_AMBISONIC_NORMALIZATION_N3D => "CLAP_AMBISONIC_NORMALIZATION_N3D", + CLAP_AMBISONIC_NORMALIZATION_SN2D => "CLAP_AMBISONIC_NORMALIZATION_SN2D", + CLAP_AMBISONIC_NORMALIZATION_N2D => "CLAP_AMBISONIC_NORMALIZATION_N2D", + _ => "?", + }, + ); + } +} diff --git a/src/plugin/ext/audio_ports.rs b/src/plugin/ext/audio_ports.rs index 3fa4569..42e3a20 100644 --- a/src/plugin/ext/audio_ports.rs +++ b/src/plugin/ext/audio_ports.rs @@ -1,25 +1,23 @@ //! Abstractions for interacting with the `audio-ports` extension. +use super::Extension; +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::ext::ambisonic::Ambisonic; +use crate::plugin::ext::surround::Surround; +use crate::plugin::instance::Plugin; +use crate::plugin::util::{clap_call, cstr_ptr_to_string}; use anyhow::{Context, Result}; -use clap_sys::ext::audio_ports::{ - clap_audio_port_info, clap_plugin_audio_ports, CLAP_EXT_AUDIO_PORTS, CLAP_PORT_MONO, - CLAP_PORT_STEREO, -}; -use clap_sys::ext::draft::ambisonic::CLAP_PORT_AMBISONIC; -use clap_sys::ext::draft::cv::CLAP_PORT_CV; -use clap_sys::ext::draft::surround::CLAP_PORT_SURROUND; -use clap_sys::id::CLAP_INVALID_ID; -use std::collections::HashMap; -use std::ffi::CStr; +use clap_sys::ext::ambisonic::CLAP_PORT_AMBISONIC; +use clap_sys::ext::audio_ports::*; +use clap_sys::ext::surround::CLAP_PORT_SURROUND; +use clap_sys::id::{CLAP_INVALID_ID, clap_id}; +use std::borrow::Cow; +use std::collections::HashSet; +use std::ffi::{CStr, c_char}; +use std::mem::zeroed; use std::ptr::NonNull; -use crate::plugin::instance::Plugin; -use crate::util::unsafe_clap_call; - -use super::Extension; - /// Abstraction for the `audio-ports` extension covering the main thread functionality. -#[derive(Debug)] pub struct AudioPorts<'a> { plugin: &'a Plugin<'a>, audio_ports: NonNull, @@ -34,26 +32,73 @@ pub struct AudioPortConfig { pub outputs: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AudioPortType(Option>); + +impl AudioPortType { + /// `null`-typed audio port. + pub const UNTYPED: Self = Self(None); + pub const MONO: Self = Self(Some(Cow::Borrowed(CLAP_PORT_MONO))); + pub const STEREO: Self = Self(Some(Cow::Borrowed(CLAP_PORT_STEREO))); + pub const SURROUND: Self = Self(Some(Cow::Borrowed(CLAP_PORT_SURROUND))); + pub const AMBISONIC: Self = Self(Some(Cow::Borrowed(CLAP_PORT_AMBISONIC))); + + pub unsafe fn from_raw(ptr: *const c_char) -> Self { + if ptr.is_null() { + Self(None) + } else { + let str = unsafe { CStr::from_ptr(ptr) }; + if str == CLAP_PORT_MONO { + Self::MONO + } else if str == CLAP_PORT_STEREO { + Self::STEREO + } else if str == CLAP_PORT_SURROUND { + Self::SURROUND + } else if str == CLAP_PORT_AMBISONIC { + Self::AMBISONIC + } else { + Self(Some(Cow::Borrowed(str))) + } + } + } +} + /// The configuration for a single audio port. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AudioPort { + /// Stable ID of the audio port. + pub id: clap_id, + + /// Whether this is the main audio port. + pub is_main: bool, + + /// The type of the audio port. + pub port_type: AudioPortType, + /// The number of channels for an audio port. - pub num_channels: u32, - /// The index if the output/input port this input/output port should be connected to. This is - /// the index in the other **port list**, not a stable ID (which have already been translated). - pub in_place_pair_idx: Option, + pub channel_count: u32, + + /// The stable ID of the output/input port this input/output port should be connected to. + pub in_place_pair: Option, + + /// Supports 64 bit processing + pub supports_double_sample_size: bool, + + /// Prefers 64 bit processing + pub prefers_double_sample_size: bool, + + /// All ports with this flag require common sample size + pub requires_common_sample_size: bool, } -impl<'a> Extension<&'a Plugin<'a>> for AudioPorts<'a> { - const EXTENSION_ID: &'static CStr = CLAP_EXT_AUDIO_PORTS; +impl<'a> Extension for AudioPorts<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_AUDIO_PORTS]; + type Plugin = &'a Plugin<'a>; type Struct = clap_plugin_audio_ports; - fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { - Self { - plugin, - audio_ports: extension_struct, - } + unsafe fn new(plugin: &'a Plugin<'a>, audio_ports: NonNull) -> Self { + Self { plugin, audio_ports } } } @@ -62,220 +107,302 @@ impl AudioPorts<'_> { /// consistency checks on the plugin's audio port configuration. pub fn config(&self) -> Result { let mut config = AudioPortConfig::default(); + let num_inputs = self.get_raw_port_count(true); + let num_outputs = self.get_raw_port_count(false); - // TODO: Refactor this to reduce the duplication a little without hurting the human readable error messages - let audio_ports = self.audio_ports.as_ptr(); - let plugin = self.plugin.as_ptr(); - let num_inputs = unsafe_clap_call! { audio_ports=>count(plugin, true) }; - let num_outputs = unsafe_clap_call! { audio_ports=>count(plugin, false) }; - - // Audio ports have a stable ID attribute that can be used to connect input and output ports - // so the host can do in-place processing. This uses stable IDs rather than the indices in - // the list. To make it easier for us, we'll translate those stable IDs to vector indices. - // These two hashmaps are keyed by the port's stable ID, and the value is a pair containing - // the port's index in the input/output port vector, and the stable ID of its in-place pair - // port. - let mut input_stable_index_pairs: HashMap = HashMap::new(); - let mut output_stable_index_pairs: HashMap = HashMap::new(); - - for i in 0..num_inputs { - let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() }; - let success = unsafe_clap_call! { audio_ports=>get(plugin, i, true, &mut info) }; - if !success { - anyhow::bail!( - "Plugin returned an error when querying input audio port {i} ({num_inputs} \ - total input ports)." - ); - } + for index in 0..num_inputs { + let info = match self.get_raw_port_info(true, index) { + Some(info) => info, + None => { + anyhow::bail!( + "Plugin returned false when querying audio port info for input port {index} (out of \ + {num_inputs} total)" + ); + } + }; - is_audio_port_type_consistent(&info).with_context(|| { - format!( - "Inconsistent channel count for output port {i} ({num_outputs} total output \ - ports)" - ) - })?; - - // We'll convert these stable IDs to vector indices later - if input_stable_index_pairs.contains_key(&info.id) { - anyhow::bail!( - "The stable ID of input audio port {i} ({}) is a duplicate.", - info.id - ); - } - input_stable_index_pairs.insert(info.id, (i as usize, info.in_place_pair)); - - config.inputs.push(AudioPort { - num_channels: info.channel_count, - // These are reconstructed from `input_stable_index_pairs` and - // `output_stable_index_pairs` later - in_place_pair_idx: None, - }); + config.inputs.push( + check_audio_port_info_valid(self.plugin, true, index, &info) + .with_context(|| format!("Inconsistent port info for input audio port {index}"))?, + ); } - for i in 0..num_outputs { - let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() }; - let success = unsafe_clap_call! { audio_ports=>get(plugin, i, false, &mut info) }; - if !success { - anyhow::bail!( - "Plugin returned an error when querying output audio port {i} ({num_outputs} \ - total output ports)." - ); - } - - is_audio_port_type_consistent(&info).with_context(|| { - format!( - "Inconsistent channel count for output port {i} ({num_outputs} total output \ - ports)" - ) - })?; - - if output_stable_index_pairs.contains_key(&info.id) { - anyhow::bail!( - "The stable ID of output audio port {i} ({}) is a duplicate.", - info.id - ); - } - output_stable_index_pairs.insert(info.id, (i as usize, info.in_place_pair)); + for index in 0..num_outputs { + let info = match self.get_raw_port_info(false, index) { + Some(info) => info, + None => { + anyhow::bail!( + "Plugin returned false when querying audio port info for output port {index} (out of \ + {num_outputs} total)" + ); + } + }; - config.outputs.push(AudioPort { - num_channels: info.channel_count, - in_place_pair_idx: None, - }); + config.outputs.push( + check_audio_port_info_valid(self.plugin, false, index, &info) + .with_context(|| format!("Inconsistent port info for output audio port {index}"))?, + ); } - // Now we need to convert the stable in-place pair indices to vector indices - for (input_stable_id, (input_port_idx, pair_stable_id)) in input_stable_index_pairs + let has_single_precision_requires_common_port = config + .inputs .iter() - .filter(|(_, (_, pair_stable_id))| *pair_stable_id != CLAP_INVALID_ID) - { - match output_stable_index_pairs - .iter() - .find(|(output_stable_id, (_, _))| *output_stable_id == pair_stable_id) - { - // This relation should be symmetrical - Some((_, (pair_output_port_idx, output_pair_stable_id))) - if output_pair_stable_id == input_stable_id => - { - config.inputs[*input_port_idx].in_place_pair_idx = Some(*pair_output_port_idx); - config.inputs[*pair_output_port_idx].in_place_pair_idx = Some(*input_port_idx); - } - Some((output_stable_id, (pair_output_port_idx, output_pair_stable_id))) => { - anyhow::bail!( - "Input port {input_port_idx} with stable ID {input_stable_id} is \ - connected to output port {pair_output_port_idx} with stable ID \ - {output_stable_id} through an in-place pair, but the relation is not \ - symmetrical. The output port reports to have an in-place pair with \ - stable ID {output_pair_stable_id}." - ) - } - None => anyhow::bail!( - "Input port {input_port_idx} with stable ID {input_stable_id} claims to be \ - connected to an output port with stable ID {pair_stable_id} through an \ - in-place pair, but this port does not exist." - ), - } - } + .chain(config.outputs.iter()) + .any(|port| port.requires_common_sample_size && !port.supports_double_sample_size); - // This needs to be repeated for output ports that are connected to input ports in case an - // output port has a stable ID pair but the corresponding input port does not - for (output_stable_id, (output_port_idx, pair_stable_id)) in output_stable_index_pairs + let has_double_precision_requires_common_port = config + .inputs .iter() - .filter(|(_, (_, pair_stable_id))| *pair_stable_id != CLAP_INVALID_ID) - { - match input_stable_index_pairs - .iter() - .find(|(input_stable_id, (_, _))| *input_stable_id == pair_stable_id) - { - Some((_, (pair_input_port_idx, input_pair_stable_id))) - if input_pair_stable_id == output_stable_id => - { - // We should have already done this. If this is not the case, then this is an - // error in the validator - assert_eq!( - config.inputs[*output_port_idx].in_place_pair_idx, - Some(*pair_input_port_idx) - ); - assert_eq!( - config.inputs[*pair_input_port_idx].in_place_pair_idx, - Some(*output_port_idx) - ); - } - Some((input_stable_id, (pair_input_port_idx, input_pair_stable_id))) => { + .chain(config.outputs.iter()) + .any(|port| port.requires_common_sample_size && port.supports_double_sample_size); + + // this implies that the common sample size requirement is useless (i.e. every port can only support + // 32bit sample size) and nullifies the 64 bit support of the other ports + if has_single_precision_requires_common_port && has_double_precision_requires_common_port { + anyhow::bail!( + "The plugin has audio ports that require common sample size, but some of these ports only support \ + 32-bit sample size while others support 64-bit sample size." + ); + } + + // check for duplicate stable IDs + for is_input in [true, false] { + let mut ids = HashSet::new(); + let ports = if is_input { &config.inputs } else { &config.outputs }; + + for (index, port) in ports.iter().enumerate() { + if !ids.insert(port.id) { anyhow::bail!( - "Output port {output_port_idx} with stable ID {output_stable_id} is \ - connected to input port {pair_input_port_idx} with stable ID \ - {input_stable_id} through an in-place pair, but the relation is not \ - symmetrical. The input port reports to have an in-place pair with stable \ - ID {input_pair_stable_id}." - ) + "Found {} audio port ({}) with a duplicate ID ({}).", + if is_input { "input" } else { "output" }, + index, + port.id + ); } - None => anyhow::bail!( - "Output port {output_port_idx} with stable ID {output_stable_id} claims to be \ - connected to an input port with stable ID {pair_stable_id} through an \ - in-place pair, but this port does not exist." - ), } } Ok(config) } + + fn get_raw_port_count(&self, is_input: bool) -> u32 { + let audio_ports = self.audio_ports.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports::count", + record! { + is_input: is_input + }, + ); + + let result = unsafe { + clap_call! { audio_ports=>count(plugin, is_input) } + }; + + span.finish(record!(result: result)); + result + } + + fn get_raw_port_info(&self, is_input: bool, port_index: u32) -> Option { + let audio_ports = self.audio_ports.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports::get", + record! { + is_input: is_input, + port_index: port_index + }, + ); + + unsafe { + let mut info = clap_audio_port_info { ..zeroed() }; + if clap_call! { audio_ports=>get(plugin, port_index, is_input, &mut info) } { + span.finish(record!(result: info)); + Some(info) + } else { + None + } + } + } +} + +pub fn check_audio_port_info_valid( + plugin: &Plugin, + is_input: bool, + port_index: u32, + info: &clap_audio_port_info, +) -> Result { + let ext_ambisonic = plugin.get_extension::(); + let ext_surround = plugin.get_extension::(); + + if info.id == CLAP_INVALID_ID { + anyhow::bail!("The stable ID is `CLAP_INVALID_ID`."); + } + + // if the main port flag is set, the port index must be 0 + let is_main = (info.flags & CLAP_AUDIO_PORT_IS_MAIN) != 0; + if is_main && port_index != 0 { + anyhow::bail!("Port is marked as main, but it is not the first port in the list."); + } + + let supports_double_sample_size = (info.flags & CLAP_AUDIO_PORT_SUPPORTS_64BITS) != 0; + let requires_common_sample_size = (info.flags & CLAP_AUDIO_PORT_REQUIRES_COMMON_SAMPLE_SIZE) != 0; + let prefers_double_sample_size = (info.flags & CLAP_AUDIO_PORT_PREFERS_64BITS) != 0; + + if !supports_double_sample_size && prefers_double_sample_size { + anyhow::bail!("Port prefers 64-bit sample size, but does not support it."); + } + + let port_type = unsafe { AudioPortType::from_raw(info.port_type) }; + + // check consistency between port type and channel count / extensions + check_audio_port_type_consistent( + is_input, + port_index, + &port_type, + info.channel_count, + ext_ambisonic.as_ref(), + ext_surround.as_ref(), + )?; + + Ok(AudioPort { + id: info.id, + is_main: (info.flags & CLAP_AUDIO_PORT_IS_MAIN) != 0, + channel_count: info.channel_count, + port_type, + in_place_pair: if info.in_place_pair == CLAP_INVALID_ID { + None + } else { + Some(info.in_place_pair) + }, + + supports_double_sample_size, + requires_common_sample_size, + prefers_double_sample_size, + }) } -/// Check whether the number of channels matches an audio port's type string, if that is set. -/// Returns an error if the port type is not consistent -fn is_audio_port_type_consistent(info: &clap_audio_port_info) -> Result<()> { - if info.port_type.is_null() { +/// Check if the returned port information consistent with the audio port type, ambisonic extension, surround extension, etc. +/// Returns an error if the port information is not consistent. +pub fn check_audio_port_type_consistent( + is_input: bool, + port_index: u32, + port_type: &AudioPortType, + channel_count: u32, + ext_ambisonic: Option<&Ambisonic>, + ext_surround: Option<&Surround>, +) -> Result<()> { + if port_type == &AudioPortType::UNTYPED { return Ok(()); } - let port_type = unsafe { CStr::from_ptr(info.port_type) }; - if port_type == CLAP_PORT_MONO { - if info.channel_count == 1 { + if port_type == &AudioPortType::MONO { + if channel_count == 1 { Ok(()) } else { anyhow::bail!( - "Expected 1 channel, but the audio port has {} channels.", - info.channel_count + "Audio port type is 'mono', but the audio port has {} channels.", + channel_count ); } - } else if port_type == CLAP_PORT_STEREO { - if info.channel_count == 2 { + } else if port_type == &AudioPortType::STEREO { + if channel_count == 2 { Ok(()) } else { anyhow::bail!( - "Expected 2 channels, but the audio port has {} channel(s).", - info.channel_count + "Audio port type is 'stereo', but the audio port has {} channel(s).", + channel_count + ); + } + } else if port_type == &AudioPortType::SURROUND { + let Some(ext_surround) = ext_surround else { + anyhow::bail!("Audio port type is 'surround', but the plugin does not implement the 'surround' extension."); + }; + + let channel_map = ext_surround.get_channel_map(is_input, port_index, channel_count); + if channel_map.len() as u32 != channel_count { + anyhow::bail!( + "The surround channel map returned by 'clap_plugin_surround::get_channel_map' has length {}, but the \ + audio port has {} channels.", + channel_map.len(), + channel_count + ); + } + + let mask = channel_map.iter().fold(0u64, |acc, &ch| acc | (1u64 << ch)); + if !ext_surround.is_channel_mask_supported(mask) { + anyhow::bail!( + "The surround channel mask {mask:#b} returned by 'clap_plugin_surround::get_channel_map' is not \ + supported by the plugin ('clap_plugin_surround::is_channel_mask_supported' returned false)." + ); + } + + Ok(()) + } else if port_type == &AudioPortType::AMBISONIC { + let Some(ext_ambisonic) = ext_ambisonic else { + anyhow::bail!( + "Audio port type is 'ambisonic', but the plugin does not implement the 'ambisonic' extension." + ); + }; + + // ambisonic audio requires (N^2) channels where N is the ambisonics order + if channel_count.isqrt().pow(2) != channel_count { + anyhow::bail!( + "Expected a perfect square (N^2 where N is the ambisonics order) number of channels for ambisonic \ + audio port, but the audio port has {} channels.", + channel_count ); } - } else if port_type == CLAP_PORT_SURROUND - || port_type == CLAP_PORT_CV - || port_type == CLAP_PORT_AMBISONIC - { - // TODO: Test the channel counts by querying those extensions + + let config = ext_ambisonic + .get_config(is_input, port_index) + .context("Failed to get ambisonic configuration for the port.")?; + + if !ext_ambisonic.is_config_supported(&config) { + anyhow::bail!( + "The ambisonic configuration returned by 'clap_plugin_ambisonic::get_config' is not supported by the \ + plugin ('clap_plugin_ambisonic::is_config_supported' returned false).", + ); + } + Ok(()) } else { - log::debug!("TODO: Unknown audio port type '{port_type:?}'"); + log::warn!("Unknown audio port type '{port_type:?}'"); Ok(()) } } -impl AudioPortConfig { - /// Create a pair of zero initialized `(input_buffers, output_buffers)` for this audio port - /// configuration. These can be bassed with - /// [`ProcessData`][super::audio_thread::process::ProcessData] to create a process data struct. - #[allow(clippy::type_complexity)] - pub fn create_buffers(&self, buffer_size: usize) -> (Vec>>, Vec>>) { - let input_buffers: Vec>> = self - .inputs - .iter() - .map(|port_config| vec![vec![0.0; buffer_size]; port_config.num_channels as usize]) - .collect(); - let output_buffers: Vec>> = self - .outputs - .iter() - .map(|port_config| vec![vec![0.0; buffer_size]; port_config.num_channels as usize]) - .collect(); +impl Recordable for clap_audio_port_info { + fn record(&self, record: &mut dyn Recorder) { + record.record("id", self.id); + record.record("channel_count", self.channel_count); - (input_buffers, output_buffers) + record.record("flags.is_main", self.flags & CLAP_AUDIO_PORT_IS_MAIN != 0); + record.record( + "flags.supports_double_sample_size", + self.flags & CLAP_AUDIO_PORT_SUPPORTS_64BITS != 0, + ); + record.record( + "flags.prefers_double_sample_size", + self.flags & CLAP_AUDIO_PORT_PREFERS_64BITS != 0, + ); + record.record( + "flags.requires_common_sample_size", + self.flags & CLAP_AUDIO_PORT_REQUIRES_COMMON_SAMPLE_SIZE != 0, + ); + + match unsafe { cstr_ptr_to_string(self.port_type) } { + Ok(Some(port_type)) => record.record("port_type", port_type), + Ok(None) => record.record("port_type", "null"), + Err(_) => record.record("port_type", ""), + } + + if self.in_place_pair == CLAP_INVALID_ID { + record.record("in_place_pair", ""); + } else { + record.record("in_place_pair", self.in_place_pair); + } } } diff --git a/src/plugin/ext/audio_ports_activation.rs b/src/plugin/ext/audio_ports_activation.rs new file mode 100644 index 0000000..797889c --- /dev/null +++ b/src/plugin/ext/audio_ports_activation.rs @@ -0,0 +1,113 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::{Plugin, PluginAudioThread}; +use crate::plugin::util::clap_call; +use clap_sys::ext::audio_ports_activation::*; +use std::ffi::CStr; +use std::ptr::NonNull; + +/// Abstraction for the `audio-ports-activation` extension covering the main thread functionality. +pub struct AudioPortsActivation<'a> { + plugin: &'a Plugin<'a>, + audio_ports_activation: NonNull, +} + +pub struct AudioPortsActivationAudio<'a> { + plugin: &'a PluginAudioThread<'a>, + audio_ports_activation: NonNull, +} + +impl<'a> Extension for AudioPortsActivation<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_AUDIO_PORTS_ACTIVATION, CLAP_EXT_AUDIO_PORTS_ACTIVATION_COMPAT]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_audio_ports_activation; + + unsafe fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { + Self { + plugin, + audio_ports_activation: extension_struct, + } + } +} + +impl<'a> Extension for AudioPortsActivationAudio<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_AUDIO_PORTS_ACTIVATION, CLAP_EXT_AUDIO_PORTS_ACTIVATION_COMPAT]; + + type Plugin = &'a PluginAudioThread<'a>; + type Struct = clap_plugin_audio_ports_activation; + + unsafe fn new(plugin: &'a PluginAudioThread<'a>, audio_ports_activation: NonNull) -> Self { + Self { + plugin, + audio_ports_activation, + } + } +} + +impl<'a> AudioPortsActivation<'a> { + pub fn can_activate_while_processing(&self) -> bool { + let audio_ports_activation = self.audio_ports_activation.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_audio_ports_activation::can_activate_while_processing", ()); + let result = unsafe { + clap_call! { audio_ports_activation=>can_activate_while_processing(plugin) } + }; + + span.finish(record!(result: result)); + result + } + + /// Activates or deactivates a single audio port. + pub fn set_active(&self, is_input: bool, port_index: u32, is_active: bool, sample_size: u32) -> bool { + self.plugin.status().assert_inactive(); + + let audio_ports_activation = self.audio_ports_activation.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports_activation::set_active", + record! { + is_input: is_input, + port_index: port_index, + is_active: is_active, + sample_size: sample_size + }, + ); + + let result = unsafe { + clap_call! { audio_ports_activation=>set_active(plugin, is_input, port_index, is_active, sample_size) } + }; + + span.finish(record!(result: result)); + result + } +} + +impl<'a> AudioPortsActivationAudio<'a> { + /// Activates or deactivates a single audio port. Only allowed if `can_activate_while_processing` returns `true`. + pub fn set_active(&self, is_input: bool, port_index: u32, is_active: bool, sample_size: u32) -> bool { + self.plugin.status().assert_active(); + + let audio_ports_activation = self.audio_ports_activation.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports_activation::set_active", + record! { + is_input: is_input, + port_index: port_index, + is_active: is_active, + sample_size: sample_size + }, + ); + + let result = unsafe { + clap_call! { audio_ports_activation=>set_active(plugin, is_input, port_index, is_active, sample_size) } + }; + + span.finish(record!(result: result)); + result + } +} diff --git a/src/plugin/ext/audio_ports_config.rs b/src/plugin/ext/audio_ports_config.rs new file mode 100644 index 0000000..e61f386 --- /dev/null +++ b/src/plugin/ext/audio_ports_config.rs @@ -0,0 +1,244 @@ +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::ext::audio_ports::{AudioPort, AudioPortType, check_audio_port_info_valid}; +use crate::plugin::instance::Plugin; +use crate::plugin::util::{c_char_slice_to_string, clap_call, cstr_ptr_to_string}; +use anyhow::Result; +use clap_sys::ext::audio_ports::clap_audio_port_info; +use clap_sys::ext::audio_ports_config::*; +use clap_sys::id::clap_id; +use std::ffi::CStr; +use std::mem::zeroed; +use std::ptr::NonNull; + +pub struct AudioPortsConfig<'a> { + plugin: &'a Plugin<'a>, + audio_ports_config: NonNull, +} + +pub struct AudioPortsConfigInfo<'a> { + plugin: &'a Plugin<'a>, + audio_ports_config_info: NonNull, +} + +/// A configuration +#[derive(Debug, Clone)] +pub struct AudioPortsConfigConfig { + pub id: clap_id, + pub name: String, + + pub input_port_count: u32, + pub output_port_count: u32, + + pub main_input_port_type: Option, + pub main_output_port_type: Option, + + pub main_input_channel_count: Option, + pub main_output_channel_count: Option, +} + +impl<'a> Extension for AudioPortsConfig<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_AUDIO_PORTS_CONFIG]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_audio_ports_config; + + unsafe fn new(plugin: &'a Plugin<'a>, audio_ports_config: NonNull) -> Self { + Self { + plugin, + audio_ports_config, + } + } +} + +impl<'a> Extension for AudioPortsConfigInfo<'a> { + const IDS: &'static [&'static CStr] = &[ + CLAP_EXT_AUDIO_PORTS_CONFIG_INFO, + CLAP_EXT_AUDIO_PORTS_CONFIG_INFO_COMPAT, + ]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_audio_ports_config_info; + + unsafe fn new(plugin: &'a Plugin<'a>, audio_ports_config_info: NonNull) -> Self { + Self { + plugin, + audio_ports_config_info, + } + } +} + +impl AudioPortsConfig<'_> { + pub fn enumerate(&self) -> Result> { + (0..self.get_raw_config_count()) + .map(|i| unsafe { + let info = self.get_raw_config_info(i)?; + + Ok(AudioPortsConfigConfig { + id: info.id, + name: c_char_slice_to_string(&info.name)?, + main_input_port_type: info + .has_main_input + .then(|| AudioPortType::from_raw(info.main_input_port_type)), + main_output_port_type: info + .has_main_output + .then(|| AudioPortType::from_raw(info.main_output_port_type)), + input_port_count: info.input_port_count, + output_port_count: info.output_port_count, + main_input_channel_count: info.has_main_input.then_some(info.main_input_channel_count), + main_output_channel_count: info.has_main_output.then_some(info.main_output_channel_count), + }) + }) + .collect() + } + + pub fn select(&self, config_id: clap_id) -> Result<()> { + let audio_ports_config = self.audio_ports_config.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports_config::select", + record! { + config_id: config_id + }, + ); + + let result = unsafe { + clap_call! { audio_ports_config=>select(plugin, config_id) } + }; + + span.finish(record!(result: result)); + + if !result { + anyhow::bail!("audio_ports_config::select() returned false"); + } + + Ok(()) + } + + fn get_raw_config_count(&self) -> u32 { + let audio_ports_config = self.audio_ports_config.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_audio_ports_config::count", ()); + let result = unsafe { + clap_call! { audio_ports_config=>count(plugin) } + }; + + span.finish(record!(result: result)); + result + } + + fn get_raw_config_info(&self, index: u32) -> Result { + let audio_ports_config = self.audio_ports_config.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports_config::get_raw_config_info", + record! { + index: index + }, + ); + + unsafe { + let mut info = clap_audio_ports_config { ..zeroed() }; + if clap_call! { audio_ports_config=>get(plugin, index, &mut info) } { + span.finish(record!(result: info)); + Ok(info) + } else { + span.finish(record!(result: false)); + anyhow::bail!( + "audio_ports_config::get({}) returned false ({} total configs)", + index, + self.get_raw_config_count() + ); + } + } + } +} + +impl AudioPortsConfigInfo<'_> { + /// Get the current selected audio ports configuration ID. + pub fn current(&self) -> clap_id { + let audio_ports_config_info = self.audio_ports_config_info.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_audio_ports_config_info::current_config", ()); + let result = unsafe { + clap_call! { audio_ports_config_info=>current_config(plugin) } + }; + + span.finish(record!(result: result)); + result + } + + /// Get information about an audio port for a configuration. + pub fn get(&self, config_id: clap_id, is_input: bool, port_index: u32) -> Result { + let info = self.get_raw_port_info(config_id, is_input, port_index)?; + check_audio_port_info_valid(self.plugin, is_input, port_index, &info) + } + + fn get_raw_port_info(&self, config_id: clap_id, is_input: bool, port_index: u32) -> Result { + let audio_ports_config_info = self.audio_ports_config_info.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_audio_ports_config_info::get", + record! { + config_id: config_id, + is_input: is_input, + port_index: port_index + }, + ); + + unsafe { + let mut info = clap_audio_port_info { ..zeroed() }; + if clap_call! { audio_ports_config_info=>get(plugin, config_id, port_index, is_input, &mut info) } { + span.finish(record!(result: info)); + Ok(info) + } else { + span.finish(record!(result: false)); + anyhow::bail!("audio_ports_config_info::get() returned false"); + } + } + } +} + +impl Recordable for clap_audio_ports_config { + fn record(&self, record: &mut dyn Recorder) { + record.record("id", self.id); + record.record( + "name", + c_char_slice_to_string(&self.name).unwrap_or_else(|_| "".to_string()), + ); + + record.record("input_port_count", self.input_port_count); + record.record("output_port_count", self.output_port_count); + + if self.has_main_input { + record.record("has_main_input", true); + record.record("main_input_channel_count", self.main_input_channel_count); + + match unsafe { cstr_ptr_to_string(self.main_input_port_type) } { + Ok(Some(port_type)) => record.record("main_input_port_type", port_type), + Ok(None) => record.record("main_input_port_type", "null"), + Err(_) => record.record("main_input_port_type", ""), + } + } else { + record.record("has_main_input", false); + } + + if self.has_main_output { + record.record("has_main_output", true); + record.record("main_output_channel_count", self.main_output_channel_count); + + match unsafe { cstr_ptr_to_string(self.main_output_port_type) } { + Ok(Some(port_type)) => record.record("main_output_port_type", port_type), + Ok(None) => record.record("main_output_port_type", "null"), + Err(_) => record.record("main_output_port_type", ""), + } + } else { + record.record("has_main_output", false); + } + } +} diff --git a/src/plugin/ext/configurable_audio_ports.rs b/src/plugin/ext/configurable_audio_ports.rs new file mode 100644 index 0000000..2494205 --- /dev/null +++ b/src/plugin/ext/configurable_audio_ports.rs @@ -0,0 +1,253 @@ +use crate::cli::tracing::{Recordable, Recorder, Span, from_fn, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use clap_sys::ext::ambisonic::{CLAP_PORT_AMBISONIC, clap_ambisonic_config}; +use clap_sys::ext::audio_ports::{CLAP_PORT_MONO, CLAP_PORT_STEREO}; +use clap_sys::ext::configurable_audio_ports::*; +use clap_sys::ext::surround::*; +use std::ffi::CStr; +use std::fmt::{Debug, Display}; +use std::ptr::{NonNull, null}; + +#[derive(Debug, Clone, Copy)] +pub struct AudioPortsRequest<'a> { + pub is_input: bool, + pub port_index: u32, + pub request_info: AudioPortsRequestInfo<'a>, +} + +/// Different types of port details that can be requested. +#[derive(Debug, Clone, Copy)] +pub enum AudioPortsRequestInfo<'a> { + Mono, + Stereo, + Untyped { + channel_count: u32, + }, + + Ambisonic { + channel_count: u32, + config: &'a clap_ambisonic_config, + }, + + Surround { + channel_map: &'a [u8], + }, +} + +pub struct ConfigurableAudioPorts<'a> { + plugin: &'a Plugin<'a>, + configurable_audio_ports: NonNull, +} + +impl<'a> Extension for ConfigurableAudioPorts<'a> { + const IDS: &'static [&'static CStr] = &[ + CLAP_EXT_CONFIGURABLE_AUDIO_PORTS, + CLAP_EXT_CONFIGURABLE_AUDIO_PORTS_COMPAT, + ]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_configurable_audio_ports; + + unsafe fn new(plugin: &'a Plugin<'a>, configurable_audio_ports: NonNull) -> Self { + Self { + plugin, + configurable_audio_ports, + } + } +} + +impl<'a> ConfigurableAudioPorts<'a> { + pub fn can_apply_configuration(&self, requests: &[AudioPortsRequest]) -> bool { + self.plugin.status().assert_inactive(); + + let span = Span::begin( + "clap_plugin_configurable_audio_ports::can_apply_configuration", + from_fn(|record| { + for (i, request) in requests.iter().enumerate() { + record.record(&format!("requests.{}", i), *request); + } + }), + ); + + let requests = convert_requests(requests.iter().copied()); + let plugin = self.plugin.as_ptr(); + let ext = self.configurable_audio_ports.as_ptr(); + + unsafe { + let result = clap_call! { ext=>can_apply_configuration( + plugin, + requests.as_ptr(), + requests.len() as u32 + )}; + + span.finish(record!(result: result)); + result + } + } + + pub fn apply_configuration(&self, requests: &[AudioPortsRequest]) -> bool { + self.plugin.status().assert_inactive(); + + let span = Span::begin( + "clap_plugin_configurable_audio_ports::apply_configuration", + from_fn(|record| { + for (i, request) in requests.iter().enumerate() { + record.record(&format!("requests.{}", i), *request); + } + }), + ); + + let requests = convert_requests(requests.iter().copied()); + let plugin = self.plugin.as_ptr(); + let ext = self.configurable_audio_ports.as_ptr(); + + unsafe { + let result = clap_call! { ext=>apply_configuration( + plugin, + requests.as_ptr(), + requests.len() as u32 + )}; + + span.finish(record!(result: result)); + result + } + } +} + +impl<'a> AudioPortsRequestInfo<'a> { + pub fn channel_count(&self) -> u32 { + match self { + AudioPortsRequestInfo::Mono => 1, + AudioPortsRequestInfo::Stereo => 2, + AudioPortsRequestInfo::Untyped { channel_count } => *channel_count, + AudioPortsRequestInfo::Ambisonic { channel_count, .. } => *channel_count, + AudioPortsRequestInfo::Surround { channel_map } => channel_map.len() as u32, + } + } + + pub fn port_type(&self) -> Option<&'a CStr> { + match self { + AudioPortsRequestInfo::Mono => Some(CLAP_PORT_MONO), + AudioPortsRequestInfo::Stereo => Some(CLAP_PORT_STEREO), + AudioPortsRequestInfo::Ambisonic { .. } => Some(CLAP_PORT_AMBISONIC), + AudioPortsRequestInfo::Surround { .. } => Some(CLAP_PORT_SURROUND), + AudioPortsRequestInfo::Untyped { .. } => None, + } + } +} + +fn convert_requests<'a>( + requests: impl IntoIterator>, +) -> Vec { + requests + .into_iter() + .map(|r| clap_audio_port_configuration_request { + is_input: r.is_input, + port_index: r.port_index, + channel_count: r.request_info.channel_count(), + port_type: r.request_info.port_type().map_or(null(), |f| f.as_ptr()), + port_details: match r.request_info { + AudioPortsRequestInfo::Surround { channel_map } => channel_map.as_ptr() as *const _, + AudioPortsRequestInfo::Ambisonic { config, .. } => config as *const clap_ambisonic_config as *const _, + _ => null(), + }, + }) + .collect::>() +} + +impl Recordable for AudioPortsRequest<'_> { + fn record(&self, record: &mut dyn Recorder) { + record.record("is_input", self.is_input); + record.record("port_index", self.port_index); + record.record("details", self.request_info); + } +} + +impl Recordable for AudioPortsRequestInfo<'_> { + fn record(&self, record: &mut dyn Recorder) { + fn surround_map_to_string(channel_map: &[u8]) -> String { + channel_map + .iter() + .map(|&ch| match ch as u32 { + CLAP_SURROUND_FL => "FL", + CLAP_SURROUND_FR => "FR", + CLAP_SURROUND_FC => "FC", + CLAP_SURROUND_LFE => "LFE", + CLAP_SURROUND_BL => "BL", + CLAP_SURROUND_BR => "BR", + CLAP_SURROUND_FLC => "FLC", + CLAP_SURROUND_FRC => "FRC", + CLAP_SURROUND_BC => "BC", + CLAP_SURROUND_SL => "SL", + CLAP_SURROUND_SR => "SR", + CLAP_SURROUND_TC => "TC", + CLAP_SURROUND_TFL => "TFL", + CLAP_SURROUND_TFC => "TFC", + CLAP_SURROUND_TFR => "TFR", + CLAP_SURROUND_TBL => "TBL", + CLAP_SURROUND_TBC => "TBC", + CLAP_SURROUND_TBR => "TBR", + _ => "?", + }) + .collect::>() + .join(" ") + } + + match self { + AudioPortsRequestInfo::Mono => { + record.record("type", "mono"); + record.record("channel_count", 1); + } + AudioPortsRequestInfo::Stereo => { + record.record("type", "stereo"); + record.record("channel_count", 2); + } + AudioPortsRequestInfo::Untyped { channel_count } => { + record.record("type", "null"); + record.record("channel_count", *channel_count); + } + AudioPortsRequestInfo::Ambisonic { channel_count, config } => { + record.record("type", "ambisonic"); + record.record("channel_count", *channel_count); + record.record("config", config); + } + AudioPortsRequestInfo::Surround { channel_map } => { + record.record("type", "surround"); + record.record("channel_count", channel_map.len() as u32); + record.record("channel_map", surround_map_to_string(channel_map)); + } + } + } +} + +impl Display for AudioPortsRequest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} #{}: {}", + if self.is_input { "Input" } else { "Output" }, + self.port_index, + self.request_info + ) + } +} + +impl Display for AudioPortsRequestInfo<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AudioPortsRequestInfo::Mono => write!(f, "Mono"), + AudioPortsRequestInfo::Stereo => write!(f, "Stereo"), + AudioPortsRequestInfo::Untyped { channel_count } => { + write!(f, "Untyped ({}ch)", channel_count) + } + AudioPortsRequestInfo::Ambisonic { channel_count, .. } => { + write!(f, "Ambisonic ({}ch)", channel_count) + } + AudioPortsRequestInfo::Surround { channel_map } => { + write!(f, "Surround ({}ch)", channel_map.len()) + } + } + } +} diff --git a/src/plugin/ext/latency.rs b/src/plugin/ext/latency.rs new file mode 100644 index 0000000..5b20096 --- /dev/null +++ b/src/plugin/ext/latency.rs @@ -0,0 +1,42 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::{Plugin, PluginStatus}; +use crate::plugin::util::clap_call; +use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_plugin_latency}; +use std::ffi::CStr; +use std::ptr::NonNull; + +#[allow(unused)] +pub struct Latency<'a> { + plugin: &'a Plugin<'a>, + latency: NonNull, +} + +impl<'a> Extension for Latency<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_LATENCY]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_latency; + + unsafe fn new(plugin: &'a Plugin<'a>, latency: NonNull) -> Self { + Self { plugin, latency } + } +} + +impl<'a> Latency<'a> { + #[allow(unused)] + pub fn get(&self) -> u32 { + self.plugin.status().assert_is_not(PluginStatus::Deactivated); + + let latency = self.latency.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_latency::get", ()); + let result = unsafe { + clap_call! { latency=>get(plugin) } + }; + + span.finish(record!(result: result)); + result + } +} diff --git a/src/plugin/ext/note_ports.rs b/src/plugin/ext/note_ports.rs index 214fa66..0d6799b 100644 --- a/src/plugin/ext/note_ports.rs +++ b/src/plugin/ext/note_ports.rs @@ -1,21 +1,18 @@ //! Abstractions for interacting with the `note-ports` extension. -use anyhow::Result; -use clap_sys::ext::note_ports::{ - clap_note_dialect, clap_note_port_info, clap_plugin_note_ports, CLAP_EXT_NOTE_PORTS, -}; +use super::Extension; +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use anyhow::{Context, Result}; +use clap_sys::ext::note_ports::*; +use clap_sys::id::CLAP_INVALID_ID; use std::collections::HashSet; use std::ffi::CStr; use std::mem; use std::ptr::NonNull; -use crate::plugin::instance::Plugin; -use crate::util::unsafe_clap_call; - -use super::Extension; - /// Abstraction for the `note-ports` extension covering the main thread functionality. -#[derive(Debug)] pub struct NotePorts<'a> { plugin: &'a Plugin<'a>, note_ports: NonNull, @@ -33,23 +30,19 @@ pub struct NotePortConfig { /// The configuration for a single note port. #[derive(Debug, Clone)] pub struct NotePort { - /// The preferred dialect for this note port. This should only ever contain a single value. - pub prefered_dialect: clap_note_dialect, /// All supported note dialects for this port. All of these note dialect values will only ever /// contain a single value. pub supported_dialects: Vec, } -impl<'a> Extension<&'a Plugin<'a>> for NotePorts<'a> { - const EXTENSION_ID: &'static CStr = CLAP_EXT_NOTE_PORTS; +impl<'a> Extension for NotePorts<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_NOTE_PORTS]; + type Plugin = &'a Plugin<'a>; type Struct = clap_plugin_note_ports; - fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { - Self { - plugin, - note_ports: extension_struct, - } + unsafe fn new(plugin: &'a Plugin<'a>, note_ports: NonNull) -> Self { + Self { plugin, note_ports } } } @@ -59,99 +52,170 @@ impl NotePorts<'_> { pub fn config(&self) -> Result { let mut config = NotePortConfig::default(); - let note_ports = self.note_ports.as_ptr(); - let plugin = self.plugin.as_ptr(); - let num_inputs = unsafe_clap_call! { note_ports=>count(plugin, true) }; - let num_outputs = unsafe_clap_call! { note_ports=>count(plugin, false) }; + let num_inputs = self.get_raw_port_count(true); + let num_outputs = self.get_raw_port_count(false); // We don't need the port's stable IDs, but we'll still verify that they're unique let mut input_stable_indices: HashSet = HashSet::new(); let mut output_stable_indices: HashSet = HashSet::new(); - for i in 0..num_inputs { - let mut info: clap_note_port_info = unsafe { std::mem::zeroed() }; - let success = unsafe_clap_call! { note_ports=>get(plugin, i, true, &mut info) }; - if !success { - anyhow::bail!( - "Plugin returned an error when querying input note port {i} ({num_inputs} \ - total input ports)." - ); - } + for index in 0..num_inputs { + let info = self.get_raw_port_info(true, index)?; - let num_preferred_dialects = info.preferred_dialect.count_ones(); - if num_preferred_dialects != 1 { - anyhow::bail!( - "Plugin prefers {num_preferred_dialects} dialects for input note port {i}." - ); + if !input_stable_indices.insert(info.id) { + anyhow::bail!("The stable ID of input note port {index} ({}) is a duplicate.", info.id); } - if (info.supported_dialects & info.preferred_dialect) == 0 { - anyhow::bail!( - "Plugin prefers note dialect {:#b} for input note port {i} which is not \ - contained within the supported note dialects field ({:#b}).", - info.preferred_dialect, - info.supported_dialects - ); - } + config.inputs.push( + check_note_port_valid(&info) + .with_context(|| format!("Inconsistent port info for input note port {index}"))?, + ); + } - if !input_stable_indices.insert(info.id) { + for index in 0..num_outputs { + let info = self.get_raw_port_info(false, index)?; + + if !output_stable_indices.insert(info.id) { anyhow::bail!( - "The stable ID of input note port {i} ({}) is a duplicate.", + "The stable ID of output note port {index} ({}) is a duplicate.", info.id ); } - config.inputs.push(NotePort { - prefered_dialect: info.preferred_dialect, - supported_dialects: (0..(mem::size_of::() * 8) - 1) - .map(|bit| 1 << bit) - .filter(|flag| (info.supported_dialects & flag) != 0) - .collect(), - }); + config.outputs.push( + check_note_port_valid(&info) + .with_context(|| format!("Inconsistent port info for output note port {index}"))?, + ); } - for i in 0..num_outputs { - let mut info: clap_note_port_info = unsafe { std::mem::zeroed() }; - let success = unsafe_clap_call! { note_ports=>get(plugin, i, true, &mut info) }; - if !success { - anyhow::bail!( - "Plugin returned an error when querying output note port {i} ({num_outputs} \ - total output ports)." - ); - } + Ok(config) + } - let num_preferred_dialects = info.preferred_dialect.count_ones(); - if num_preferred_dialects != 1 { - anyhow::bail!( - "Plugin prefers {num_preferred_dialects} dialects for output note port {i}." - ); - } + fn get_raw_port_count(&self, is_input: bool) -> u32 { + let note_ports = self.note_ports.as_ptr(); + let plugin = self.plugin.as_ptr(); - if (info.supported_dialects & info.preferred_dialect) == 0 { - anyhow::bail!( - "Plugin prefers note dialect {:#b} for output note port {i} which is not \ - contained within the supported note dialects field ({:#b}).", - info.preferred_dialect, - info.supported_dialects - ); - } + let span = Span::begin( + "clap_plugin_note_ports::count", + record! { + is_input: is_input + }, + ); - if !output_stable_indices.insert(info.id) { + let result = unsafe { + clap_call! { note_ports=>count(plugin, is_input) } + }; + + span.finish(record!(result: result)); + result + } + + fn get_raw_port_info(&self, is_input: bool, port_index: u32) -> Result { + let note_ports = self.note_ports.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_note_ports::get", + record! { + is_input: is_input, + port_index: port_index + }, + ); + + unsafe { + let mut info = clap_note_port_info { ..std::mem::zeroed() }; + let result = clap_call! { note_ports=>get(plugin, port_index, is_input, &mut info) }; + if result { + span.finish(record!(result: info)); + Ok(info) + } else { + span.finish(record!(result: false)); anyhow::bail!( - "The stable ID of output note port {i} ({}) is a duplicate.", - info.id + "Plugin returned false when querying {} note port {port_index} ({} total {} ports).", + if is_input { "input" } else { "output" }, + self.get_raw_port_count(is_input), + if is_input { "input" } else { "output" } ); } - - config.outputs.push(NotePort { - prefered_dialect: info.preferred_dialect, - supported_dialects: (0..(mem::size_of::() * 8) - 1) - .map(|bit| 1 << bit) - .filter(|flag| (info.supported_dialects & flag) != 0) - .collect(), - }); } + } +} - Ok(config) +impl NotePort { + pub fn supports_clap(&self) -> bool { + self.supported_dialects.contains(&CLAP_NOTE_DIALECT_CLAP) + } + + pub fn supports_midi(&self) -> bool { + self.supported_dialects.contains(&CLAP_NOTE_DIALECT_MIDI) + || self.supported_dialects.contains(&CLAP_NOTE_DIALECT_MIDI_MPE) + } +} + +fn check_note_port_valid(info: &clap_note_port_info) -> Result { + if info.id == CLAP_INVALID_ID { + anyhow::bail!("The stable ID is `CLAP_INVALID_ID`."); + } + + let num_preferred_dialects = info.preferred_dialect.count_ones(); + if num_preferred_dialects != 1 { + anyhow::bail!( + "`preferred_dialect` contains multiple ({num_preferred_dialects}) dialect values, must be exactly one." + ); + } + + if (info.supported_dialects & info.preferred_dialect) == 0 { + anyhow::bail!( + "Port prefers note dialect {:#b} which is not contained within the supported note dialects field ({:#b}).", + info.preferred_dialect, + info.supported_dialects + ); + } + + Ok(NotePort { + supported_dialects: (0..(mem::size_of::() * 8) - 1) + .map(|bit| 1 << bit) + .filter(|flag| (info.supported_dialects & flag) != 0) + .collect(), + }) +} + +impl Recordable for clap_note_port_info { + fn record(&self, record: &mut dyn Recorder) { + record.record("id", self.id); + + record.record( + "supported_dialects.clap", + self.supported_dialects & CLAP_NOTE_DIALECT_CLAP != 0, + ); + record.record( + "supported_dialects.midi", + self.supported_dialects & CLAP_NOTE_DIALECT_MIDI != 0, + ); + record.record( + "supported_dialects.midi_mpe", + self.supported_dialects & CLAP_NOTE_DIALECT_MIDI_MPE != 0, + ); + record.record( + "supported_dialects.midi2", + self.supported_dialects & CLAP_NOTE_DIALECT_MIDI2 != 0, + ); + + record.record( + "preferred_dialect.clap", + self.preferred_dialect & CLAP_NOTE_DIALECT_CLAP != 0, + ); + record.record( + "preferred_dialect.midi", + self.preferred_dialect & CLAP_NOTE_DIALECT_MIDI != 0, + ); + record.record( + "preferred_dialect.midi_mpe", + self.preferred_dialect & CLAP_NOTE_DIALECT_MIDI_MPE != 0, + ); + record.record( + "preferred_dialect.midi2", + self.preferred_dialect & CLAP_NOTE_DIALECT_MIDI2 != 0, + ); } } diff --git a/src/plugin/ext/params.rs b/src/plugin/ext/params.rs index 1ee0ed1..811645d 100644 --- a/src/plugin/ext/params.rs +++ b/src/plugin/ext/params.rs @@ -1,59 +1,48 @@ //! Abstractions for interacting with the `params` extension. +use super::Extension; +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::instance::Plugin; +use crate::plugin::process::{InputEventQueue, OutputEventQueue}; +use crate::plugin::util::{self, Proxy, c_char_slice_to_string, clap_call}; use anyhow::{Context, Result}; -use clap_sys::events::{clap_input_events, clap_output_events}; -use clap_sys::ext::params::{ - clap_param_info, clap_param_info_flags, clap_plugin_params, CLAP_EXT_PARAMS, - CLAP_PARAM_IS_AUTOMATABLE, CLAP_PARAM_IS_AUTOMATABLE_PER_CHANNEL, - CLAP_PARAM_IS_AUTOMATABLE_PER_KEY, CLAP_PARAM_IS_AUTOMATABLE_PER_NOTE_ID, - CLAP_PARAM_IS_AUTOMATABLE_PER_PORT, CLAP_PARAM_IS_BYPASS, CLAP_PARAM_IS_HIDDEN, - CLAP_PARAM_IS_MODULATABLE, CLAP_PARAM_IS_MODULATABLE_PER_CHANNEL, - CLAP_PARAM_IS_MODULATABLE_PER_KEY, CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID, - CLAP_PARAM_IS_MODULATABLE_PER_PORT, CLAP_PARAM_IS_READONLY, CLAP_PARAM_IS_STEPPED, -}; -use clap_sys::id::clap_id; +use clap_sys::ext::params::*; +use clap_sys::id::{CLAP_INVALID_ID, clap_id}; use clap_sys::string_sizes::CLAP_NAME_SIZE; use std::collections::BTreeMap; -use std::ffi::{c_void, CStr, CString}; +use std::ffi::{CStr, CString, c_void}; use std::ops::RangeInclusive; -use std::pin::Pin; use std::ptr::NonNull; -use super::Extension; -use crate::plugin::assert_plugin_state_lt; -use crate::plugin::instance::process::EventQueue; -use crate::plugin::instance::{Plugin, PluginStatus}; -use crate::util::{self, c_char_slice_to_string, unsafe_clap_call}; - pub type ParamInfo = BTreeMap; /// Abstraction for the `params` extension covering the main thread functionality. -#[derive(Debug)] pub struct Params<'a> { plugin: &'a Plugin<'a>, params: NonNull, } -impl<'a> Extension<&'a Plugin<'a>> for Params<'a> { - const EXTENSION_ID: &'static CStr = CLAP_EXT_PARAMS; +impl<'a> Extension for Params<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_PARAMS]; + type Plugin = &'a Plugin<'a>; type Struct = clap_plugin_params; - fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { - Self { - plugin, - params: extension_struct, - } + unsafe fn new(plugin: &'a Plugin<'a>, params: NonNull) -> Self { + Self { plugin, params } } } /// Information about a parameter. #[derive(Debug, Clone)] pub struct Param { + /// Display name of the parameter. pub name: String, + /// This is the module name for the parameter. + pub module: String, /// This should be provided to the plugin when sending automation or modulation events for this /// parameter. - pub cookie: *mut c_void, + pub cookie: Option>, /// The parameter's value range. pub range: RangeInclusive, /// The parameter's default value. @@ -62,23 +51,36 @@ pub struct Param { pub flags: clap_param_info_flags, } -impl Params<'_> { - /// Used by the status assertion macros. - fn status(&self) -> PluginStatus { - self.plugin.status() - } +/// Type of rescan requested by the plugin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParamsRescan { + Values, + Text, + Info, + All, +} + +unsafe impl Send for Param {} +unsafe impl Sync for Param {} +impl Params<'_> { /// Get a parameter's value. pub fn get(&self, param_id: clap_id) -> Result { let params = self.params.as_ptr(); let plugin = self.plugin.as_ptr(); let mut value = 0.0f64; - if unsafe_clap_call! { params=>get_value(plugin, param_id, &mut value) } { + + let span = Span::begin("clap_plugin_params::get_value", record! { param_id: param_id }); + let result = unsafe { + clap_call! { params=>get_value(plugin, param_id, &mut value) } + }; + + if result { + span.finish(record!(result: value)); Ok(value) } else { - anyhow::bail!( - "'clap_plugin_params::get_value()' returned false for parameter ID {param_id}." - ); + span.finish(record!(result: false)); + anyhow::bail!("'clap_plugin_params::get_value()' returned false for parameter ID {param_id}."); } } @@ -88,26 +90,40 @@ impl Params<'_> { pub fn value_to_text(&self, param_id: clap_id, value: f64) -> Result> { let params = self.params.as_ptr(); let plugin = self.plugin.as_ptr(); - let mut string_buffer = [0; CLAP_NAME_SIZE]; - if unsafe_clap_call! { - params=>value_to_text( - plugin, - param_id, - value, - string_buffer.as_mut_ptr(), - string_buffer.len() as u32, - ) - } { - c_char_slice_to_string(&string_buffer) - .map(Some) - .with_context(|| { - format!( - "Could not convert the string representation of {value} for parameter \ - {param_id} to a UTF-8 string" - ) - }) - } else { - Ok(None) + + let span = Span::begin( + "clap_plugin_params::value_to_text", + record! { param_id: param_id, value: value }, + ); + + unsafe { + let mut string_buffer = [0; CLAP_NAME_SIZE]; + let result = clap_call! { + params=>value_to_text( + plugin, + param_id, + value, + string_buffer.as_mut_ptr(), + string_buffer.len() as u32, + ) + }; + + if result { + match c_char_slice_to_string(&string_buffer) { + Ok(s) => { + span.finish(record!(result: &s)); + Ok(Some(s)) + } + Err(_) => { + span.finish(record!(result: "")); + anyhow::bail!( + "The string representation of {value} for parameter {param_id} contains invalid UTF-8." + ) + } + } + } else { + Ok(None) + } } } @@ -118,18 +134,29 @@ impl Params<'_> { let params = self.params.as_ptr(); let plugin = self.plugin.as_ptr(); - let mut value = 0.0f64; - if unsafe_clap_call! { - params=>text_to_value( - plugin, - param_id, - text_cstring.as_ptr(), - &mut value, - ) - } { - Ok(Some(value)) - } else { - Ok(None) + + let span = Span::begin( + "clap_plugin_params::text_to_value", + record! { param_id: param_id, text: text }, + ); + + unsafe { + let mut value = 0.0f64; + let result = clap_call! { + params=>text_to_value( + plugin, + param_id, + text_cstring.as_ptr(), + &mut value, + ) + }; + + if result { + span.finish(record!(result: value)); + Ok(Some(value)) + } else { + Ok(None) + } } } @@ -139,106 +166,97 @@ impl Params<'_> { /// BTreeMap to ensure the order is consistent between runs. pub fn info(&self) -> Result { let mut result = BTreeMap::new(); - - let params = self.params.as_ptr(); - let plugin = self.plugin.as_ptr(); - let num_params = unsafe_clap_call! { params=>count(plugin) }; + let num_params = self.get_raw_param_count(); // Right now this is only used to make sure the plugin doesn't have multiple bypass parameters let mut bypass_parameter_id = None; for i in 0..num_params { - let mut info: clap_param_info = unsafe { std::mem::zeroed() }; - let success = unsafe_clap_call! { params=>get_info(plugin, i, &mut info) }; - if !success { - anyhow::bail!( - "Plugin returned an error when querying parameter {i} ({num_params} total \ - parameters)." - ); + let info = self.get_raw_param_info(i)?; + + if info.id == CLAP_INVALID_ID { + anyhow::bail!("The stable ID for parameter {i} is `CLAP_INVALID_ID`."); } - let name = util::c_char_slice_to_string(&info.name).with_context(|| { - format!( - "Could not read the name for parameter with stable ID {}", - info.id - ) - })?; + let name = util::c_char_slice_to_string(&info.name) + .with_context(|| format!("Could not read the name for parameter with stable ID {}", info.id))?; - // We don't use the module string, but we'll still check it for consistency. Basically - // anything goes here as long as there are no trailing, leading, or multiple subsequent - // slashes. - let module = util::c_char_slice_to_string(&info.name).with_context(|| { + let module = util::c_char_slice_to_string(&info.module).with_context(|| { format!( "Could not read the module name for parameter '{}' (stable ID {})", - &name, info.id + name, info.id ) })?; + if module.starts_with('/') { anyhow::bail!( - "The module name for parameter '{}' (stable ID {}) starts with a leading \ - slash: '{}'.", - &name, + "The module name for parameter '{}' (stable ID {}) starts with a leading slash: '{}'.", + name, info.id, module ) - } else if module.ends_with('/') { + } + + if module.ends_with('/') { anyhow::bail!( - "The module name for parameter '{}' (stable ID {}) ends with a trailing \ - slash: '{}'.", - &name, + "The module name for parameter '{}' (stable ID {}) ends with a trailing slash: '{}'.", + name, info.id, module ) - } else if module.contains("//") { + } + + if module.contains("//") { anyhow::bail!( - "The module name for parameter '{}' (stable ID {}) contains multiple \ - subsequent slashes: '{}'.", - &name, + "The module name for parameter '{}' (stable ID {}) contains multiple subsequent slashes: '{}'.", + name, info.id, module ) } - let range = info.min_value..=info.max_value; if info.min_value > info.max_value { anyhow::bail!( - "Parameter '{}' (stable ID {}) has a minimum value ({:?}) that's higher than \ - it's maximum value ({:?}).", - &name, + "Parameter '{}' (stable ID {}) has a minimum value ({:?}) that's higher than it's maximum value \ + ({:?}).", + name, info.id, info.min_value, info.max_value ) } - if !range.contains(&info.default_value) { + + if !(info.min_value..=info.max_value).contains(&info.default_value) { anyhow::bail!( - "Parameter '{}' (stable ID {}) has a default value ({:?}) that falls outside \ - of its value range ({:?}).", - &name, + "Parameter '{}' (stable ID {}) has a default value ({:?}) that falls outside of its value range \ + ({:?}).", + name, info.id, info.default_value, - &range + info.min_value..=info.max_value ) } + if (info.flags & CLAP_PARAM_IS_STEPPED) != 0 { if info.min_value != info.min_value.trunc() { anyhow::bail!( - "Parameter '{}' (stable ID {}) is a stepped parameter, but its minimum \ - value ({:?}) is not an integer.", - &name, + "Parameter '{}' (stable ID {}) is a stepped parameter, but its minimum value ({:?}) is not an \ + integer.", + name, info.id, info.min_value, ) } if info.max_value != info.max_value.trunc() { anyhow::bail!( - "Parameter '{}' (stable ID {}) is a stepped parameter, but its maximum \ - value ({:?}) is not an integer.", - &name, + "Parameter '{}' (stable ID {}) is a stepped parameter, but its maximum value ({:?}) is not an \ + integer.", + name, info.id, info.max_value, ) } } + if (info.flags & CLAP_PARAM_IS_BYPASS) != 0 { match bypass_parameter_id { Some(bypass_parameter_id) => anyhow::bail!( @@ -251,9 +269,8 @@ impl Params<'_> { if (info.flags & CLAP_PARAM_IS_STEPPED) == 0 { anyhow::bail!( - "Parameter '{}' (stable ID {}) is a bypass parameter, but it is not \ - stepped.", - &name, + "Parameter '{}' (stable ID {}) is a bypass parameter, but it is not stepped.", + name, info.id ) } @@ -271,12 +288,13 @@ impl Params<'_> { != 0 { anyhow::bail!( - "Parameter '{}' (stable ID {}) is automatable per note ID, key, channel, or \ - port, but does not have CLAP_PARAM_IS_AUTOMATABLE. This is likely a bug.", - &name, + "Parameter '{}' (stable ID {}) is automatable per note ID, key, channel, or port, but does not \ + have CLAP_PARAM_IS_AUTOMATABLE. This is likely a bug.", + name, info.id ) } + if (info.flags & CLAP_PARAM_IS_MODULATABLE) == 0 && (info.flags & (CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID @@ -286,36 +304,35 @@ impl Params<'_> { != 0 { anyhow::bail!( - "Parameter '{}' (stable ID {}) is modulatable per note ID, key, channel, or \ - port, but does not have CLAP_PARAM_IS_MODULATABLE. This is likely a bug.", - &name, + "Parameter '{}' (stable ID {}) is modulatable per note ID, key, channel, or port, but does not \ + have CLAP_PARAM_IS_MODULATABLE. This is likely a bug.", + name, info.id ) } + if ((info.flags & CLAP_PARAM_IS_READONLY) != 0) - && ((info.flags & CLAP_PARAM_IS_AUTOMATABLE) != 0 - || (info.flags & CLAP_PARAM_IS_MODULATABLE) != 0) + && ((info.flags & CLAP_PARAM_IS_AUTOMATABLE) != 0 || (info.flags & CLAP_PARAM_IS_MODULATABLE) != 0) { anyhow::bail!( - "Parameter '{}' (stable ID {}) has the CLAP_PARAM_IS_READONLY flag set, but \ - it is also marked as automatable or modulatable. This is likely a bug.", - &name, + "Parameter '{}' (stable ID {}) has the 'CLAP_PARAM_IS_READONLY' flag set, but it is also marked \ + as automatable or modulatable. This is likely a bug.", + name, info.id ) } let processed_info = Param { name, - cookie: info.cookie, - range, + module, + cookie: NonNull::new(info.cookie), + range: info.min_value..=info.max_value, default: info.default_value, flags: info.flags, }; + if result.insert(info.id, processed_info).is_some() { - anyhow::bail!( - "The plugin contains multiple parameters with stable ID {}.", - info.id - ); + anyhow::bail!("The plugin contains multiple parameters with stable ID {}.", info.id); } } @@ -323,44 +340,200 @@ impl Params<'_> { } /// Perform a parameter flush. - /// - /// # Panics - /// - /// Panics if the plugin is active. - pub fn flush( - &self, - input_events: &Pin>>, - output_events: &Pin>>, - ) { + pub fn flush(&self, input_events: &Proxy, output_events: &Proxy) { // This may only be called on the audio thread when the plugin is active. This object is the // main thread interface for the parameters extension. - assert_plugin_state_lt!(self, PluginStatus::Activated); + self.plugin.status().assert_inactive(); + + let params = self.params.as_ptr(); + let plugin = self.plugin.as_ptr(); + + unsafe { + let _span = Span::begin("clap_plugin_params::flush", ()); + clap_call! { + params=>flush( + plugin, + Proxy::vtable(input_events), + Proxy::vtable(output_events), + ) + }; + } + } + fn get_raw_param_count(&self) -> u32 { let params = self.params.as_ptr(); let plugin = self.plugin.as_ptr(); - unsafe_clap_call! { - params=>flush( - plugin, - input_events.vtable(), - output_events.vtable(), - ) + + let span = Span::begin("clap_plugin_params::count", ()); + let result = unsafe { + clap_call! { params=>count(plugin) } }; + + span.finish(record!(result: result)); + result + } + + fn get_raw_param_info(&self, index: u32) -> Result { + let params = self.params.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_params::get_info", record! { index: index }); + unsafe { + let mut result = clap_param_info { ..std::mem::zeroed() }; + if !clap_call! { params=>get_info(plugin, index, &mut result) } { + let num_params = self.get_raw_param_count(); + anyhow::bail!("Plugin returned false when querying parameter {index} ({num_params} total parameters)."); + } + + span.finish(record!(result: result)); + Ok(result) + } } } impl Param { + /// Returns the `ParamsRescan` that should be requested if the parameter has changed from `previous` to `self`. + pub fn needs_rescan(&self, previous: &Param) -> Option { + if self.cookie != previous.cookie + || self.range != previous.range + || self.default != previous.default + || self.is_readonly() != previous.is_readonly() + || self.is_stepped() != previous.is_stepped() + || self.is_automatable() != previous.is_automatable() + || self.is_modulatable() != previous.is_modulatable() + { + return Some(ParamsRescan::All); + } + + if self.is_hidden() != previous.is_hidden() + || self.is_periodic() != previous.is_periodic() + || self.name != previous.name + || self.module != previous.module + { + return Some(ParamsRescan::Info); + } + + None + } + /// Whether the parameter is hidden and should be ignored. - pub fn hidden(&self) -> bool { + pub fn is_hidden(&self) -> bool { (self.flags & CLAP_PARAM_IS_HIDDEN) != 0 } /// Whether the parameter is read-only and should not be changed. - pub fn readonly(&self) -> bool { + pub fn is_readonly(&self) -> bool { (self.flags & CLAP_PARAM_IS_READONLY) != 0 } /// Whether this parameter is stepped. - pub fn stepped(&self) -> bool { + pub fn is_stepped(&self) -> bool { (self.flags & CLAP_PARAM_IS_STEPPED) != 0 } + + /// Whether this parameter is periodic. + pub fn is_periodic(&self) -> bool { + (self.flags & CLAP_PARAM_IS_PERIODIC) != 0 + } + + /// Whether this parameter is automatable. + pub fn is_automatable(&self) -> bool { + (self.flags & CLAP_PARAM_IS_AUTOMATABLE) != 0 + } + + /// Whether this parameter is automatable per note ID, key, channel, or port. + pub fn is_poly_automatable(&self) -> bool { + (self.flags + & (CLAP_PARAM_IS_AUTOMATABLE_PER_NOTE_ID + | CLAP_PARAM_IS_AUTOMATABLE_PER_KEY + | CLAP_PARAM_IS_AUTOMATABLE_PER_CHANNEL + | CLAP_PARAM_IS_AUTOMATABLE_PER_PORT)) + != 0 + } + + /// Whether this parameter is modulatable. + pub fn is_modulatable(&self) -> bool { + (self.flags & CLAP_PARAM_IS_MODULATABLE) != 0 + } + + /// Whether this parameter is modulatable per note ID, key, channel, or port. + pub fn is_poly_modulatable(&self) -> bool { + (self.flags + & (CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID + | CLAP_PARAM_IS_MODULATABLE_PER_KEY + | CLAP_PARAM_IS_MODULATABLE_PER_CHANNEL + | CLAP_PARAM_IS_MODULATABLE_PER_PORT)) + != 0 + } +} + +impl Recordable for clap_param_info { + fn record(&self, record: &mut dyn Recorder) { + record.record("id", self.id); + + record.record( + "name", + c_char_slice_to_string(&self.name).unwrap_or_else(|_| "".to_string()), + ); + + record.record( + "module", + c_char_slice_to_string(&self.module).unwrap_or_else(|_| "".to_string()), + ); + + record.record("cookie", format_args!("{:p}", self.cookie)); + record.record("min_value", self.min_value); + record.record("max_value", self.max_value); + record.record("default_value", self.default_value); + + record.record("flags.is_hidden", self.flags & CLAP_PARAM_IS_HIDDEN != 0); + record.record("flags.is_readonly", self.flags & CLAP_PARAM_IS_READONLY != 0); + record.record("flags.is_stepped", self.flags & CLAP_PARAM_IS_STEPPED != 0); + record.record("flags.is_periodic", self.flags & CLAP_PARAM_IS_PERIODIC != 0); + record.record("flags.is_bypass", self.flags & CLAP_PARAM_IS_BYPASS != 0); + record.record("flags.is_enum", self.flags & CLAP_PARAM_IS_ENUM != 0); + + record.record( + "flags.is_automatable.global", + self.flags & CLAP_PARAM_IS_AUTOMATABLE != 0, + ); + record.record( + "flags.is_automatable.per_note_id", + self.flags & CLAP_PARAM_IS_AUTOMATABLE_PER_NOTE_ID != 0, + ); + record.record( + "flags.is_automatable.per_key", + self.flags & CLAP_PARAM_IS_AUTOMATABLE_PER_KEY != 0, + ); + record.record( + "flags.is_automatable.per_channel", + self.flags & CLAP_PARAM_IS_AUTOMATABLE_PER_CHANNEL != 0, + ); + record.record( + "flags.is_automatable.per_port", + self.flags & CLAP_PARAM_IS_AUTOMATABLE_PER_PORT != 0, + ); + record.record( + "flags.is_modulatable.global", + self.flags & CLAP_PARAM_IS_MODULATABLE != 0, + ); + record.record( + "flags.is_modulatable.per_note_id", + self.flags & CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID != 0, + ); + record.record( + "flags.is_modulatable.per_key", + self.flags & CLAP_PARAM_IS_MODULATABLE_PER_KEY != 0, + ); + record.record( + "flags.is_modulatable.per_channel", + self.flags & CLAP_PARAM_IS_MODULATABLE_PER_CHANNEL != 0, + ); + record.record( + "flags.is_modulatable.per_port", + self.flags & CLAP_PARAM_IS_MODULATABLE_PER_PORT != 0, + ); + + record.record("flags.requires_process", self.flags & CLAP_PARAM_REQUIRES_PROCESS != 0); + } } diff --git a/src/plugin/ext/preset_load.rs b/src/plugin/ext/preset_load.rs index 34156af..1aa50d7 100644 --- a/src/plugin/ext/preset_load.rs +++ b/src/plugin/ext/preset_load.rs @@ -1,65 +1,73 @@ //! Abstractions for interacting with the `preset-load` extension. use anyhow::{Context, Result}; -use clap_sys::ext::draft::preset_load::{clap_plugin_preset_load, CLAP_EXT_PRESET_LOAD}; +use clap_sys::ext::preset_load::{CLAP_EXT_PRESET_LOAD, clap_plugin_preset_load}; use std::ffi::{CStr, CString}; use std::ptr::NonNull; +use super::Extension; +use crate::cli::tracing::{Span, record}; use crate::plugin::instance::Plugin; use crate::plugin::preset_discovery::LocationValue; -use crate::util::unsafe_clap_call; - -use super::Extension; +use crate::plugin::util::clap_call; /// Abstraction for the `preset-load` extension covering the main thread functionality. -#[derive(Debug)] pub struct PresetLoad<'a> { plugin: &'a Plugin<'a>, preset_load: NonNull, } -impl<'a> Extension<&'a Plugin<'a>> for PresetLoad<'a> { - const EXTENSION_ID: &'static CStr = CLAP_EXT_PRESET_LOAD; +impl<'a> Extension for PresetLoad<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_PRESET_LOAD]; + type Plugin = &'a Plugin<'a>; type Struct = clap_plugin_preset_load; - fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { - Self { - plugin, - preset_load: extension_struct, - } + unsafe fn new(plugin: &'a Plugin<'a>, preset_load: NonNull) -> Self { + Self { plugin, preset_load } } } impl PresetLoad<'_> { - /// Try to load a preet based on a location and an optional load key. This information can be + /// Try to load a preset based on a location and an optional load key. This information can be /// obtained through the preset discovery factory /// ([`Library::preset_discovery_factory()`][[crate::plugin::library::Library::preset_discovery_factory()]]). /// Load keys are only used for container presets, otherwise they're `None`. The semantics are /// similar to loading state. - #[allow(clippy::wrong_self_convention)] - pub fn from_location(&self, location: &LocationValue, load_key: Option<&str>) -> Result<()> { + pub fn load_from_location(&self, location: &LocationValue, load_key: Option<&str>) -> Result<()> { let (location_kind, location_ptr) = location.to_raw(); let load_key_cstring = load_key - .map(|load_key| { - CString::new(load_key).context("Load key contained internal null bytes") - }) + .map(|load_key| CString::new(load_key).context("Load key contained internal null bytes")) .transpose()?; let preset_load = self.preset_load.as_ptr(); let plugin = self.plugin.as_ptr(); - let success = unsafe_clap_call! { - preset_load=>from_location( - plugin, - location_kind, - location_ptr, - match load_key_cstring.as_ref() { - Some(load_key_cstring) => load_key_cstring.as_ptr(), - None => std::ptr::null(), - } - ) + + let span = Span::begin( + "clap_plugin_preset_load::from_location", + record! { + location: location, + load_key: load_key + }, + ); + + let result = unsafe { + clap_call! { + preset_load=>from_location( + plugin, + location_kind, + location_ptr, + match load_key_cstring.as_ref() { + Some(load_key_cstring) => load_key_cstring.as_ptr(), + None => std::ptr::null(), + } + ) + } }; - if success { + + span.finish(record!(result: result)); + + if result { Ok(()) } else { anyhow::bail!( diff --git a/src/plugin/ext/render.rs b/src/plugin/ext/render.rs new file mode 100644 index 0000000..4a6dc32 --- /dev/null +++ b/src/plugin/ext/render.rs @@ -0,0 +1,68 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use clap_sys::ext::render::*; +use std::ffi::CStr; +use std::ptr::NonNull; + +pub struct Render<'a> { + plugin: &'a Plugin<'a>, + render: NonNull, +} + +impl<'a> Extension for Render<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_RENDER]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_render; + + unsafe fn new(plugin: &'a Plugin<'a>, render: NonNull) -> Self { + Self { plugin, render } + } +} + +impl<'a> Render<'a> { + #[allow(unused)] + pub fn has_hard_realtime_requirement(&self) -> bool { + let render = self.render.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_render::has_hard_realtime_requirement", ()); + let result = unsafe { + clap_call! { render=>has_hard_realtime_requirement(plugin) } + }; + + span.finish(record!(result: result)); + result + } + + pub fn set(&self, mode: RenderMode) -> bool { + let render = self.render.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_render::set", + record!(mode: match mode { + RenderMode::Offline => "CLAP_RENDER_OFFLINE", + RenderMode::Realtime => "CLAP_RENDER_REALTIME", + }), + ); + + let result = unsafe { + clap_call! { render=>set(plugin, match mode { + RenderMode::Offline => CLAP_RENDER_OFFLINE, + RenderMode::Realtime => CLAP_RENDER_REALTIME, + }) } + }; + + span.finish(record!(result: result)); + result + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + Offline, + Realtime, +} diff --git a/src/plugin/ext/state.rs b/src/plugin/ext/state.rs index 305e130..4eae272 100644 --- a/src/plugin/ext/state.rs +++ b/src/plugin/ext/state.rs @@ -1,20 +1,20 @@ //! Abstractions for interacting with the `state` extension. +use super::Extension; +use crate::cli::fail_test; +use crate::cli::tracing::{Span, record}; +use crate::plugin::instance::Plugin; +use crate::plugin::util::{CHECK_POINTER, Proxy, Proxyable, clap_call}; use anyhow::Result; -use clap_sys::ext::state::{clap_plugin_state, CLAP_EXT_STATE}; +use clap_sys::ext::state::{CLAP_EXT_STATE, clap_plugin_state}; use clap_sys::stream::{clap_istream, clap_ostream}; -use parking_lot::Mutex; -use std::ffi::{c_void, CStr}; -use std::pin::Pin; +use std::ffi::{CStr, c_void}; use std::ptr::NonNull; +use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; - -use super::Extension; -use crate::plugin::instance::Plugin; -use crate::util::{check_null_ptr, unsafe_clap_call}; +use std::thread::ThreadId; /// Abstraction for the `state` extension covering the main thread functionality. -#[derive(Debug)] pub struct State<'a> { plugin: &'a Plugin<'a>, state: NonNull, @@ -23,10 +23,13 @@ pub struct State<'a> { /// An input stream backed by a slice. #[derive(Debug)] struct InputStream<'a> { - // The `ctx` pointer is set to this struct after creating the object - vtable: clap_istream, + /// The thread ID that created this stream. Used to verify that the plugin is calling the stream + /// methods from the same thread. + expected_thread_id: ThreadId, + + /// The buffer to read from. + read_buffer: &'a [u8], - buffer: &'a [u8], /// The current position when reading from the buffer. This is needed because the plugin /// provides the buffer we should copy data into, and subsequent reads should continue from /// where we were left off. @@ -39,207 +42,246 @@ struct InputStream<'a> { /// An output stream backed by a vector. #[derive(Debug)] struct OutputStream { - // The `ctx` pointer is set to this struct after creating the object - vtable: clap_ostream, + /// The thread ID that created this stream. Used to verify that the plugin is calling the stream + /// methods from the same thread. + expected_thread_id: ThreadId, // In Rust-land this function is object is only used from a single thread and there's absolutely // no reason for the plugin to be calling the stream read and write methods from multiple // threads, but better be safe than sorry. - buffer: Mutex>, + write_buffer: Mutex>, + /// The maximum number of bytes the plugin is allowed to write to this stream at a time, if the /// stream pretends to be buffered. This is used to test whether the plugin handles buffered /// streams correctly. max_write_size: Option, } -impl<'a> Extension<&'a Plugin<'a>> for State<'a> { - const EXTENSION_ID: &'static CStr = CLAP_EXT_STATE; +impl<'a> Extension for State<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_STATE]; + type Plugin = &'a Plugin<'a>; type Struct = clap_plugin_state; - fn new(plugin: &'a Plugin<'a>, extension_struct: NonNull) -> Self { - Self { - plugin, - state: extension_struct, - } + unsafe fn new(plugin: &'a Plugin<'a>, state: NonNull) -> Self { + Self { plugin, state } } } impl State<'_> { /// Retrieve the plugin's state. Returns an error if the plugin returned `false`. pub fn save(&self) -> Result> { - let stream = OutputStream::new(); - + let stream = OutputStream::new(None); let state = self.state.as_ptr(); let plugin = self.plugin.as_ptr(); - if unsafe_clap_call! { state=>save(plugin, &stream.vtable) } { - Ok(stream.into_vec()) + + let span = Span::begin("clap_plugin_state::save", ()); + let result = unsafe { + clap_call! { state=>save(plugin, Proxy::vtable(&stream)) } + }; + + span.finish(record!(result: result)); + + if result { + Ok(stream.take()) } else { - anyhow::bail!("'clap_plugin_state::save()' returned false."); + anyhow::bail!("'clap_plugin_state::save()' returned false"); } } /// Retrieve the plugin's state while limiting the number of bytes the plugin can write at a /// time. Returns an error if the plugin returned `false`. pub fn save_buffered(&self, max_bytes: usize) -> Result> { - let stream = OutputStream::new().with_buffering(max_bytes); - + let stream = OutputStream::new(Some(max_bytes)); let state = self.state.as_ptr(); let plugin = self.plugin.as_ptr(); - if unsafe_clap_call! { state=>save(plugin, stream.vtable()) } { - Ok(stream.into_vec()) + + let span = Span::begin("clap_plugin_state::save", record! { max_bytes: max_bytes }); + let result = unsafe { + clap_call! { state=>save(plugin, Proxy::vtable(&stream)) } + }; + + span.finish(record!(result: result)); + + if result { + Ok(stream.take()) } else { anyhow::bail!( - "'clap_plugin_state::save()' returned false when only allowing the plugin to \ - write {max_bytes} bytes at a time." + "'clap_plugin_state::save()' returned false when only allowing the plugin to write {max_bytes} bytes \ + at a time" ); } } /// Restore previously stored state. Returns an error if the plugin returned `false`. pub fn load(&self, state: &[u8]) -> Result<()> { - let stream = InputStream::new(state); - + let stream = InputStream::new(state, None); let state = self.state.as_ptr(); let plugin = self.plugin.as_ptr(); - if unsafe_clap_call! { state=>load(plugin, stream.vtable()) } { + + let span = Span::begin("clap_plugin_state::load", ()); + let result = unsafe { + clap_call! { state=>load(plugin, Proxy::vtable(&stream)) } + }; + + span.finish(record!(result: result)); + + if result { Ok(()) } else { - anyhow::bail!("'clap_plugin_state::load()' returned false."); + anyhow::bail!("'clap_plugin_state::load()' returned false"); } } /// Restore previously stored state while limiting the number of bytes the plugin can read at a /// time. Returns an error if the plugin returned `false`. pub fn load_buffered(&self, state: &[u8], max_bytes: usize) -> Result<()> { - let stream = InputStream::new(state).with_buffering(max_bytes); + let stream = InputStream::new(state, Some(max_bytes)); let state = self.state.as_ptr(); let plugin = self.plugin.as_ptr(); - if unsafe_clap_call! { state=>load(plugin, &stream.vtable) } { + + let span = Span::begin("clap_plugin_state::load", record! { max_bytes: max_bytes }); + let result = unsafe { + clap_call! { state=>load(plugin, Proxy::vtable(&stream)) } + }; + + span.finish(record!(result: result)); + + if result { Ok(()) } else { anyhow::bail!( - "'clap_plugin_state::load()' returned false when only allowing the plugin to read \ - {max_bytes} bytes at a time." + "'clap_plugin_state::load()' returned false when only allowing the plugin to read {max_bytes} bytes \ + at a time" ); } } } -impl<'a> InputStream<'a> { - /// Create a new input stream backed by a slice. - pub fn new(buffer: &'a [u8]) -> Pin> { - let mut stream = Box::pin(InputStream { - vtable: clap_istream { - // This is set to point to this object below - ctx: std::ptr::null_mut(), - read: Some(Self::read), - }, - - buffer, - read_position: AtomicUsize::new(0), - max_read_size: None, - }); +impl<'a> Proxyable for InputStream<'a> { + type Vtable = clap_istream; - stream.vtable.ctx = &*stream as *const Self as *mut c_void; - - stream + fn init(&self) -> Self::Vtable { + clap_istream { + ctx: CHECK_POINTER, + read: Some(Self::read), + } } +} + +impl Proxyable for OutputStream { + type Vtable = clap_ostream; - /// The stream's `clap_istream` vtable. - pub fn vtable(self: &Pin>) -> *const clap_istream { - &self.vtable + fn init(&self) -> Self::Vtable { + clap_ostream { + ctx: CHECK_POINTER, + write: Some(Self::write), + } } +} - /// Only allow `max_bytes` bytes to be read at a time. Useful for simulating buffered streams. - pub fn with_buffering(mut self: Pin>, max_bytes: usize) -> Pin> { - self.max_read_size = Some(max_bytes); - self +impl<'a> InputStream<'a> { + /// Create a new input stream backed by a slice. + pub fn new(buffer: &'a [u8], max_read_size: Option) -> Proxy { + Proxy::new(InputStream { + read_buffer: buffer, + expected_thread_id: std::thread::current().id(), + read_position: AtomicUsize::new(0), + max_read_size, + }) } unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 { - check_null_ptr!(stream, (*stream).ctx, buffer); - let this = &*((*stream).ctx as *const Self); - - // The reads may be limited to a certain buffering size to test the plugin's capabilities - let size = match this.max_read_size { - Some(max_read_size) => size.min(max_read_size as u64), - None => size, - }; - - let current_pos = this.read_position.load(Ordering::Relaxed); - let bytes_to_read = (this.buffer.len() - current_pos).min(size as usize); - this.read_position - .fetch_add(bytes_to_read, Ordering::Relaxed); - - std::slice::from_raw_parts_mut(buffer as *mut u8, bytes_to_read) - .copy_from_slice(&this.buffer[current_pos..current_pos + bytes_to_read]); - - bytes_to_read as i64 + let span = Span::begin( + "clap_istream::read", + record! { buffer: format_args!("{:p}", buffer), size: size }, + ); + + unsafe { + let state = Proxy::::from_vtable(stream).unwrap_or_else(|e| { + fail_test!("clap_istream::read: {}", e); + }); + + if Proxy::vtable(&state).ctx != CHECK_POINTER { + fail_test!("clap_istream::read: plugin messed with the 'ctx' pointer"); + } + + if state.expected_thread_id != std::thread::current().id() { + fail_test!("clap_istream::read: called from a different thread than the one that created the stream"); + } + + // The reads may be limited to a certain buffering size to test the plugin's capabilities + let size = match state.max_read_size { + Some(max_read_size) => size.min(max_read_size as u64), + None => size, + }; + + let current_pos = state.read_position.load(Ordering::Relaxed); + let bytes_to_read = (state.read_buffer.len() - current_pos).min(size as usize); + state.read_position.fetch_add(bytes_to_read, Ordering::Relaxed); + + std::slice::from_raw_parts_mut(buffer as *mut u8, bytes_to_read) + .copy_from_slice(&state.read_buffer[current_pos..current_pos + bytes_to_read]); + + span.finish(record! { bytes_read: bytes_to_read }); + bytes_to_read as i64 + } } } impl OutputStream { /// Create a new output stream backed by a vector. - pub fn new() -> Pin> { - let mut stream = Box::pin(OutputStream { - vtable: clap_ostream { - // This is set to point to this object below - ctx: std::ptr::null_mut(), - write: Some(Self::write), - }, - - buffer: Mutex::new(Vec::new()), - max_write_size: None, - }); - - stream.vtable.ctx = &*stream as *const Self as *mut c_void; - - stream - } - - /// The stream's `clap_ostream` vtable. - pub fn vtable(self: &Pin>) -> *const clap_ostream { - &self.vtable - } - - /// Only allow `max_bytes` bytes to be written at a time. Useful for simulating buffered - /// streams. - pub fn with_buffering(mut self: Pin>, max_bytes: usize) -> Pin> { - self.max_write_size = Some(max_bytes); - self + pub fn new(max_write_size: Option) -> Proxy { + Proxy::new(OutputStream { + expected_thread_id: std::thread::current().id(), + write_buffer: Mutex::new(Vec::new()), + max_write_size, + }) } - /// Get the byte buffer from this stream. - pub fn into_vec(self: Pin>) -> Vec { - // SAFETY: We can safely grab this inner buffer because this consumes the Box - unsafe { Pin::into_inner_unchecked(self) } - .buffer - .into_inner() + /// Take the contents of the write buffer. + pub fn take(&self) -> Vec { + std::mem::take(&mut *self.write_buffer.lock().unwrap()) } - unsafe extern "C" fn write( - stream: *const clap_ostream, - buffer: *const c_void, - size: u64, - ) -> i64 { - check_null_ptr!(stream, (*stream).ctx, buffer); - let this = &*((*stream).ctx as *const Self); - - // The writes may be limited to a certain buffering size to test the plugin's capabilities - let size = match this.max_write_size { - Some(max_write_size) => size.min(max_write_size as u64), - None => size, - }; - - this.buffer - .lock() - .extend_from_slice(std::slice::from_raw_parts( - buffer as *const u8, - size as usize, - )); - - size as i64 + unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 { + let span = Span::begin( + "clap_ostream::write", + record! { buffer: format_args!("{:p}", buffer), size: size }, + ); + + unsafe { + let state = Proxy::::from_vtable(stream).unwrap_or_else(|e| { + fail_test!("clap_ostream::write: {}", e); + }); + + if Proxy::vtable(&state).ctx != CHECK_POINTER { + fail_test!("clap_ostream::write: plugin messed with the 'ctx' pointer"); + } + + if buffer.is_null() { + fail_test!("clap_ostream::write: 'buffer' pointer is null"); + } + + if state.expected_thread_id != std::thread::current().id() { + fail_test!("clap_ostream::write: called from a different thread than the one that created the stream"); + } + + // The writes may be limited to a certain buffering size to test the plugin's capabilities + let size = match state.max_write_size { + Some(max_write_size) => size.min(max_write_size as u64), + None => size, + }; + + state + .write_buffer + .lock() + .unwrap() + .extend_from_slice(std::slice::from_raw_parts(buffer as *const u8, size as usize)); + + span.finish(record! { bytes_written: size }); + size as i64 + } } } diff --git a/src/plugin/ext/surround.rs b/src/plugin/ext/surround.rs new file mode 100644 index 0000000..6b8e221 --- /dev/null +++ b/src/plugin/ext/surround.rs @@ -0,0 +1,78 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use clap_sys::ext::surround::*; +use std::ffi::CStr; +use std::ptr::NonNull; + +pub struct Surround<'a> { + plugin: &'a Plugin<'a>, + surround: NonNull, +} + +impl<'a> Extension for Surround<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_SURROUND, CLAP_EXT_SURROUND_COMPAT]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_surround; + + unsafe fn new(plugin: &'a Plugin<'a>, surround: NonNull) -> Self { + Self { plugin, surround } + } +} + +impl<'a> Surround<'a> { + pub fn is_channel_mask_supported(&self, channel_mask: u64) -> bool { + let surround = self.surround.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_surround::is_channel_mask_supported", + record! { channel_mask: channel_mask }, + ); + + let result = unsafe { + clap_call! { + surround=>is_channel_mask_supported( + plugin, + channel_mask + ) + } + }; + + span.finish(record!(result: result)); + result + } + + pub fn get_channel_map(&self, is_input: bool, port_index: u32, channel_count: u32) -> Vec { + let surround = self.surround.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin( + "clap_plugin_surround::get_channel_map", + record! { + is_input: is_input, + port_index: port_index, + channel_count: channel_count + }, + ); + + unsafe { + let mut channel_map = vec![0u8; channel_count as usize]; + let channels_real = clap_call! { + surround=>get_channel_map( + plugin, + is_input, + port_index, + channel_map.as_mut_ptr(), + channel_count + ) + }; + + channel_map.truncate(channels_real as usize); + span.finish(record! { channel_map: format_args!("{:?}", channel_map) }); + channel_map + } + } +} diff --git a/src/plugin/ext/tail.rs b/src/plugin/ext/tail.rs new file mode 100644 index 0000000..b110944 --- /dev/null +++ b/src/plugin/ext/tail.rs @@ -0,0 +1,38 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::PluginAudioThread; +use crate::plugin::util::clap_call; +use clap_sys::ext::tail::{CLAP_EXT_TAIL, clap_plugin_tail}; +use std::ffi::CStr; +use std::ptr::NonNull; + +pub struct Tail<'a> { + plugin: &'a PluginAudioThread<'a>, + tail: NonNull, +} + +impl<'a> Extension for Tail<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_TAIL]; + + type Plugin = &'a PluginAudioThread<'a>; + type Struct = clap_plugin_tail; + + unsafe fn new(plugin: &'a PluginAudioThread<'a>, tail: NonNull) -> Self { + Self { plugin, tail } + } +} + +impl<'a> Tail<'a> { + pub fn get(&self) -> u32 { + let tail = self.tail.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_tail::get", ()); + let result = unsafe { + clap_call! { tail=>get(plugin) } + }; + + span.finish(record!(result: result)); + result + } +} diff --git a/src/plugin/ext/thread_pool.rs b/src/plugin/ext/thread_pool.rs new file mode 100644 index 0000000..007a114 --- /dev/null +++ b/src/plugin/ext/thread_pool.rs @@ -0,0 +1,39 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::PluginShared; +use crate::plugin::util::clap_call; +use clap_sys::ext::thread_pool::{CLAP_EXT_THREAD_POOL, clap_plugin_thread_pool}; +use std::ffi::CStr; +use std::ptr::NonNull; + +#[derive(Clone, Copy)] +pub struct ThreadPool<'a> { + plugin: &'a PluginShared, + thread_pool: NonNull, +} + +unsafe impl Send for ThreadPool<'_> {} +unsafe impl Sync for ThreadPool<'_> {} + +impl<'a> Extension for ThreadPool<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_THREAD_POOL]; + + type Plugin = &'a PluginShared; + type Struct = clap_plugin_thread_pool; + + unsafe fn new(plugin: &'a PluginShared, thread_pool: NonNull) -> Self { + Self { plugin, thread_pool } + } +} + +impl<'a> ThreadPool<'a> { + pub fn exec(&self, task: u32) { + let thread_pool = self.thread_pool.as_ptr(); + let plugin = self.plugin.clap_plugin; + + let _span = Span::begin("clap_plugin_thread_pool::exec", record! { task: task }); + unsafe { + clap_call! { thread_pool=>exec(plugin, task) } + } + } +} diff --git a/src/plugin/ext/voice_info.rs b/src/plugin/ext/voice_info.rs new file mode 100644 index 0000000..5373a28 --- /dev/null +++ b/src/plugin/ext/voice_info.rs @@ -0,0 +1,58 @@ +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::Plugin; +use crate::plugin::util::clap_call; +use clap_sys::ext::voice_info::*; +use std::ffi::CStr; +use std::mem::zeroed; +use std::ptr::NonNull; + +#[allow(unused)] +pub struct VoiceInfo<'a> { + plugin: &'a Plugin<'a>, + voice_info: NonNull, +} + +impl<'a> Extension for VoiceInfo<'a> { + const IDS: &'static [&'static CStr] = &[CLAP_EXT_VOICE_INFO]; + + type Plugin = &'a Plugin<'a>; + type Struct = clap_plugin_voice_info; + + unsafe fn new(plugin: &'a Plugin<'a>, voice_info: NonNull) -> Self { + Self { plugin, voice_info } + } +} + +impl<'a> VoiceInfo<'a> { + pub fn get(&self) -> Option { + self.plugin.status().assert_active(); + + let voice_info = self.voice_info.as_ptr(); + let plugin = self.plugin.as_ptr(); + + let span = Span::begin("clap_plugin_voice_info::get", ()); + + unsafe { + let mut result = clap_voice_info { ..zeroed() }; + if clap_call! { voice_info=>get(plugin, &mut result) } { + span.finish(record!(result: result)); + Some(result) + } else { + span.finish(record!(result: false)); + None + } + } + } +} + +impl Recordable for clap_voice_info { + fn record(&self, record: &mut dyn Recorder) { + record.record("voice_count", self.voice_count); + record.record("voice_capacity", self.voice_capacity); + record.record( + "supports_overlapping_notes", + self.flags & CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES != 0, + ); + } +} diff --git a/src/plugin/host.rs b/src/plugin/host.rs deleted file mode 100644 index 6ef406a..0000000 --- a/src/plugin/host.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! Data structures and utilities for hosting plugins. - -use anyhow::{Context, Result}; -use clap_sys::ext::audio_ports::{clap_host_audio_ports, CLAP_EXT_AUDIO_PORTS}; -use clap_sys::ext::draft::preset_load::{clap_host_preset_load, CLAP_EXT_PRESET_LOAD}; -use clap_sys::ext::note_ports::{ - clap_host_note_ports, clap_note_dialect, CLAP_EXT_NOTE_PORTS, CLAP_NOTE_DIALECT_CLAP, - CLAP_NOTE_DIALECT_MIDI, CLAP_NOTE_DIALECT_MIDI_MPE, -}; -use clap_sys::ext::params::{ - clap_host_params, clap_param_clear_flags, clap_param_rescan_flags, CLAP_EXT_PARAMS, -}; -use clap_sys::ext::state::{clap_host_state, CLAP_EXT_STATE}; -use clap_sys::ext::thread_check::{clap_host_thread_check, CLAP_EXT_THREAD_CHECK}; -use clap_sys::factory::draft::preset_discovery::clap_preset_discovery_location_kind; -use clap_sys::host::clap_host; -use clap_sys::id::clap_id; -use clap_sys::plugin::clap_plugin; -use clap_sys::version::CLAP_VERSION; -use crossbeam::atomic::AtomicCell; -use crossbeam::channel; -use parking_lot::Mutex; -use std::cell::RefCell; -use std::collections::HashMap; -use std::ffi::{c_void, CStr, CString}; -use std::os::raw::c_char; -use std::pin::Pin; -use std::rc::Rc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread::ThreadId; - -use crate::plugin::instance::{PluginHandle, PluginStatus}; -use crate::plugin::preset_discovery::LocationValue; -use crate::util::{self, check_null_ptr, unsafe_clap_call}; - -/// An abstraction for a CLAP plugin host. -/// -/// - It handles callback requests made by the plugin, and it checks whether the calling thread -/// matches up when any of its functions are called by the plugin. A `Result` indicating the first -/// failure, of any, can be retrieved by calling the -/// [`callback_error_check()`][Self::callback_error_check()] method. -/// - In order for those calblacks to be handled correctly every CLAP function call where the plugin -/// potentially requests a main thread callback [`Host::handle_callbacks_once()`] needs to be -/// called. Alternatively [`Host::handle_callbacks_blocking()`] can be called on the main thread -/// while other audio threads are doing their thing. -/// - Multiple plugins can share this host instance. Because of that, we can't just cast the `*const -/// clap_host` directly to a `*const Host`, as that would make it impossible to figure out which -/// `*const clap_host` belongs to which plugin instance. Instead, every registered plugin instance -/// gets their own `InstanceState` which provides a `clap_host` struct unique to that plugin -/// instance. This can be linked back to both the plugin instance and the shared `Host`. -#[derive(Debug)] -pub struct Host { - /// The ID of the main thread. - main_thread_id: ThreadId, - /// A description of the first error encountered during a callback by this `Host`, if any. This - /// is primarily used to check that the plugin called all host callbacks from the correct thread - /// after the rest of the test has succeeded. - callback_error: RefCell>, - - /// These are the plugin instances taht were registered on this host. They're added here when - /// the `Plugin` object is created, and they're removed when the object is dropped. This is used - /// to keep track of audio threads and pending callbacks. - instances: RefCell>>>, - - /// Allows waking up the main thread for callbacks while running - /// [`handle_callbacks_blocking()`][Self::handle_callbacks_blocking()]. Other threads can also - /// use this to cause the function to return. - pub callback_task_sender: channel::Sender, - /// Used for handling callbacks on the main thread during - /// [`handle_callbacks_blocking()`][Self::handle_callbacks_blocking()]. - callback_task_receiver: channel::Receiver, - - // These are the vtables for the extensions supported by the host - clap_host_audio_ports: clap_host_audio_ports, - clap_host_note_ports: clap_host_note_ports, - clap_host_params: clap_host_params, - clap_host_preset_load: clap_host_preset_load, - clap_host_state: clap_host_state, - clap_host_thread_check: clap_host_thread_check, -} - -/// Runtime information about a plugin instance. This keeps track of pending callbacks and things -/// like audio threads. It also contains the plugin's unique `clap_host` struct so host callbacks -/// can be linked back to this specific plugin instance. -#[derive(Debug)] -pub struct InstanceState { - /// The plugin this `InstanceState` is associated with. This is the same as they key in the - /// `Host::instances` hash map, but it also needs to be stored here to make it possible to - /// know what plugin instance a `*const clap_host` refers to. - /// - /// This is an `Option` because the plugin handle is only known after the plugin has been - /// created, and the factory's `create_plugin()` function requires a pointer to the `clap_host`. - pub plugin: AtomicCell>, - /// The host this `InstanceState` belongs to. This is needed to get back to the `Host` - /// instance from a `*const clap_host`, which we can cast to this struct to access the pointer. - host: Rc, - - /// The `clap-validator` version. Read at compile time, but it has to be stored here since it - /// needs to be null terminated. - _clap_validator_version: CString, - /// The vtable that's passed to the plugin. The `host_data` field is populated with a pointer to - /// this object. - clap_host: Mutex, - - /// The plugin's current state in terms of activation and processing status. - pub status: AtomicCell, - - /// The plugin instance's audio thread, if it has one. Used for the audio thread checks. - pub audio_thread: AtomicCell>, - /// Whether the plugin has called `clap_host::request_callback()` and expects - /// `clap_plugin::on_main_thread()` to be called on the main thread. - pub requested_callback: AtomicBool, - /// Whether the plugin has called `clap_host::request_restart()` and expects the plugin to be - /// deactivated and subsequently reactivated. - /// - /// This flag is reset at the start of the `ProcessingTest::run*` functions, and it will cause - /// the multi-loop - /// [`ProcessingTest::run`][crate::testa::plugin::processing::ProcessingTest::run] function to - /// deactivate and reactivate. - pub requested_restart: AtomicBool, -} - -/// When the host is handling callbacks in a blocking fashion, other threads can send tasks over the -/// channel to either wake up the main thread to make it check for outstanding work, or to have it -/// return and stop blocking. -pub enum CallbackTask { - /// Check the registered plugin instances for outstanding callbacks and perform them as needed. - /// The combined use of polling and channels may seem a bit odd, but this is done to have a - /// thread-safe way to avoid multiple sequential callback requests from stacking up. If the - /// plugin calls `clap_host::request_callback()` ten times in a row, then we only need to call - /// `clap_plugin::on_main()` once. - Poll, - /// Stop blocking and return from [`Host::handle_callbacks_blocking()`]. - Stop, -} - -impl InstanceState { - /// Construct a new plugin instance object. The [`InstanceState::plugin`] field must be set - /// later because the `clap_host` struct needs to be passed to `clap_factory::create_plugin()`, - /// and the plugin instance pointer is only known after that point. This contains the - /// `clap_host` vtable for this plugin instance, and keeps track of things like the instance's - /// audio thread and pending callbacks. The `Pin` is necessary to prevent moving the object out - /// of the `Arc`, since that would break pointers to the `InstanceState`. - pub fn new(host: Rc) -> Pin> { - let clap_validator_version = - CString::new(env!("CARGO_PKG_VERSION")).expect("Invalid bytes in crate version"); - let instance = Arc::pin(Self { - plugin: AtomicCell::new(None), - host, - - clap_host: Mutex::new(clap_host { - clap_version: CLAP_VERSION, - // This is populated with a pointer to the `Arc`'s data after creating the Arc - host_data: std::ptr::null_mut(), - name: b"clap-validator\0".as_ptr() as *const c_char, - vendor: b"Robbert van der Helm\0".as_ptr() as *const c_char, - url: b"https://github.com/free-audio/clap-validator\0".as_ptr() as *const c_char, - version: clap_validator_version.as_ptr(), - get_extension: Some(Host::get_extension), - request_restart: Some(Host::request_restart), - request_process: Some(Host::request_process), - request_callback: Some(Host::request_callback), - }), - _clap_validator_version: clap_validator_version, - - status: AtomicCell::new(PluginStatus::default()), - - audio_thread: AtomicCell::new(None), - requested_callback: AtomicBool::new(false), - requested_restart: AtomicBool::new(false), - }); - - // We need to get the pointer to the pinned `InstanceState` into the `clap_host::host_data` - // field - instance.clap_host.lock().host_data = &*instance as *const Self as *mut c_void; - - instance - } - - /// Get the `InstanceState` and the host from a valid `clap_host` pointer. - pub unsafe fn from_clap_host_ptr<'a>(ptr: *const clap_host) -> (&'a InstanceState, &'a Host) { - // This should have already been asserted before calling this function, but this is a - // validator and you can never be too sure - assert!(!ptr.is_null() && !(*ptr).host_data.is_null()); - - let this = &*((*ptr).host_data as *const Self); - (this, &*this.host) - } - - /// Get the host instance if this is called from the main thread. Returns `None` if this is not - /// the case. - pub fn host(&self) -> Option<&Host> { - if std::thread::current().id() == self.host.main_thread_id { - Some(&*self.host) - } else { - None - } - } - - /// Get a pointer to the `clap_host` struct for this instance. This uniquely identifies the - /// instance. - pub fn clap_host_ptr(self: &Pin>) -> *const clap_host { - // The value will not move, so this is safe - self.clap_host.data_ptr() - } - - /// Get a pointer to the `clap_plugin` struct for this instance. - /// - /// # Panics - /// - /// If the `plugin field has not yet been set. - pub fn plugin_ptr(&self) -> *const clap_plugin { - self.plugin - .load() - .expect("The 'plugin' field has not yet been set on this 'InstanceState'") - .0 - .as_ptr() - } -} - -impl Drop for Host { - fn drop(&mut self) { - if let Some(error) = self.callback_error.borrow_mut().take() { - log::error!( - "The validator's host has detected a callback error but this error has not been \ - used as part of the test result. This is a clap-validator bug. The error message \ - is: {error}" - ) - } - } -} - -impl Host { - /// Initialize a CLAP host. The thread this object is created on will be designated as the main - /// thread for the purposes of the thread safety checks. - pub fn new() -> Rc { - // Normally you'd of course use bounded channel to avoid unnecessary allocations, but since - // we're a validator it's probably better to not have to deal with the possibility that a - // queue is full. These are used for handling callbacks on the main thread while the audio - // thread is active. - let (callback_task_sender, callback_task_receiver) = channel::unbounded(); - - Rc::new(Host { - main_thread_id: std::thread::current().id(), - // If the plugin never makes callbacks from the wrong thread, then this will remain an - // None`. Otherwise this will be replaced by the first error. - callback_error: RefCell::new(None), - - instances: RefCell::new(HashMap::new()), - callback_task_sender, - callback_task_receiver, - - clap_host_audio_ports: clap_host_audio_ports { - is_rescan_flag_supported: Some(Self::ext_audio_ports_is_rescan_flag_supported), - rescan: Some(Self::ext_audio_ports_rescan), - }, - clap_host_note_ports: clap_host_note_ports { - supported_dialects: Some(Self::ext_note_ports_supported_dialects), - rescan: Some(Self::ext_note_ports_rescan), - }, - clap_host_preset_load: clap_host_preset_load { - on_error: Some(Self::ext_preset_load_on_error), - loaded: Some(Self::ext_preset_load_loaded), - }, - clap_host_params: clap_host_params { - rescan: Some(Self::ext_params_rescan), - clear: Some(Self::ext_params_clear), - request_flush: Some(Self::ext_params_request_flush), - }, - clap_host_state: clap_host_state { - mark_dirty: Some(Self::ext_state_mark_dirty), - }, - clap_host_thread_check: clap_host_thread_check { - is_main_thread: Some(Self::ext_thread_check_is_main_thread), - is_audio_thread: Some(Self::ext_thread_check_is_audio_thread), - }, - }) - } - - /// Register a plugin instance with the host. This is used to keep track of things like audio - /// thread IDs and pending callbacks. This also contains the `*const clap_host` that should be - /// paased to the plugin when its created. - /// - /// The plugin should be unregistered using - /// [`unregister_instance()`][Self::unregister_instance()] when it gets destroyed. - /// - /// # Panics - /// - /// Panics if `instance.plugin` is `None`, or if the instance has already been registered. - pub fn register_instance(&self, instance: Pin>) { - let previous_instance = self.instances.borrow_mut().insert( - instance.plugin.load().expect( - "'InstanceState::plugin' should contain the plugin's handle when registering it \ - with the host", - ), - instance.clone(), - ); - assert!( - previous_instance.is_none(), - "The plugin instance has already been registered" - ); - } - - /// Remove a plugin from the list of registered plugins. - pub fn unregister_instance(&self, instance: Pin>) { - let removed_instance = self - .instances - .borrow_mut() - .remove(&instance.plugin.load().expect( - "'InstanceState::plugin' should contain the plugin's handle when unregistering it \ - with the host", - )) - .expect( - "Tried unregistering a plugin instance that has not been registered with the host", - ); - - if removed_instance.requested_callback.load(Ordering::SeqCst) { - log::warn!( - "A plugin still had unhandled callbacks when it was removed. This is a \ - clap-validator bug." - ) - } - } - - /// Handle main thread callbacks until [`CallbackTask::Stop`] is send to - /// [`Host::callback_task_sender`] from another thread. - pub fn handle_callbacks_blocking(&self) { - let mut should_stop = false; - loop { - if should_stop { - break; - } - - let task = self.callback_task_receiver.recv().unwrap(); - if matches!(task, CallbackTask::Stop) { - should_stop = true; - } - - // Flush all poll messages, if the plugin rapid fired a bunch of callbacks at us. We - // only keep track of a single request per callback type to avoid these things from - // unnecessarily stacking up. - while let Ok(callback) = self.callback_task_receiver.try_recv() { - match callback { - CallbackTask::Poll => (), - CallbackTask::Stop => should_stop = true, - } - } - - // This function will handle up to ten recursive callback requests. We'll do this even - // if the handler should be stopped to make sure we did not miss any outstanding events. - self.handle_callbacks_once(); - } - } - - /// Handle pending main thread callbacks. If a callback results in another callback, this is - /// allowed to loop up to ten times. - pub fn handle_callbacks_once(&self) { - let instances = self.instances.borrow(); - for i in 0..10 { - let mut handled_callback = false; - for instance in instances.values() { - let plugin_ptr = instance.plugin_ptr(); - if instance - .requested_callback - .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - log::trace!( - "Calling 'clap_plugin::on_main_thread()' in response to a call to \ - 'clap_host::request_restart()'", - ); - unsafe_clap_call! { plugin_ptr=>on_main_thread(plugin_ptr) }; - handled_callback = true; - } - } - - if !handled_callback { - if i > 1 { - log::trace!( - "The plugin recursively requested callbacks {} times in a row", - i - ) - } - - return; - } - } - - log::warn!( - "The plugin recursively called 'clap_host::on_main_thread()'. Aborted after ten \ - iterations." - ) - } - - /// Check if any of the host's callbacks were called from the wrong thread. Returns the first - /// error if this happened. If there were errors and this function is not called before the - /// object is destroyed, an error will be logged. - pub fn callback_error_check(&self) -> Result<()> { - match self.callback_error.borrow_mut().take() { - Some(err) => anyhow::bail!(err), - None => Ok(()), - } - } - - /// Set the callback error field if it does not already contain a value. Earlier errors are not - /// overwritten. - fn set_callback_error(&self, error: impl Into) { - let mut callback_error = self.callback_error.borrow_mut(); - if callback_error.is_none() { - *callback_error = Some(error.into()); - } - } - - /// Checks whether this is the main thread. If it is not, then an error indicating this can be - /// retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread - /// safety errors will not overwrite earlier ones. - fn assert_main_thread(&self, function_name: &str) { - let current_thread_id = std::thread::current().id(); - if current_thread_id != self.main_thread_id { - self.set_callback_error(format!( - "'{}' may only be called from the main thread (thread {:?}), but it was called \ - from thread {:?}.", - function_name, self.main_thread_id, current_thread_id - )); - } - } - - /// Checks whether this is the audio thread. If it is not, then an error indicating this can be - /// retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread - /// safety errors will not overwrite earlier ones. - #[allow(unused)] - fn assert_audio_thread(&self, function_name: &str) { - let current_thread_id = std::thread::current().id(); - if !self.is_audio_thread(current_thread_id) { - if current_thread_id == self.main_thread_id { - self.set_callback_error(format!( - "'{function_name}' may only be called from an audio thread, but it was called \ - from the main thread." - )); - } else { - self.set_callback_error(format!( - "'{function_name}' may only be called from an audio thread, but it was called \ - from an unknown thread." - )); - } - } - } - - /// Checks whether this is **not** the audio thread. If it is, then an error indicating this can - /// be retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread - /// safety errors will not overwrite earlier ones. - fn assert_not_audio_thread(&self, function_name: &str) { - let current_thread_id = std::thread::current().id(); - if self.is_audio_thread(current_thread_id) { - self.set_callback_error(format!( - "'{function_name}' was called from an audio thread, this is not allowed.", - )); - } - } - - /// Returns whether the thread ID is one of the registered audio threads. - fn is_audio_thread(&self, thread_id: ThreadId) -> bool { - self.instances - .borrow() - .values() - .any(|instance| instance.audio_thread.load() == Some(thread_id)) - } - - unsafe extern "C" fn get_extension( - host: *const clap_host, - extension_id: *const c_char, - ) -> *const c_void { - check_null_ptr!(host, (*host).host_data, extension_id); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - // Right now there's no way to have the host only expose certain extensions. We can always - // add that when test cases need it. - let extension_id_cstr = CStr::from_ptr(extension_id); - if extension_id_cstr == CLAP_EXT_AUDIO_PORTS { - &this.clap_host_audio_ports as *const _ as *const c_void - } else if extension_id_cstr == CLAP_EXT_NOTE_PORTS { - &this.clap_host_note_ports as *const _ as *const c_void - } else if extension_id_cstr == CLAP_EXT_PRESET_LOAD { - &this.clap_host_preset_load as *const _ as *const c_void - } else if extension_id_cstr == CLAP_EXT_PARAMS { - &this.clap_host_params as *const _ as *const c_void - } else if extension_id_cstr == CLAP_EXT_STATE { - &this.clap_host_state as *const _ as *const c_void - } else if extension_id_cstr == CLAP_EXT_THREAD_CHECK { - &this.clap_host_thread_check as *const _ as *const c_void - } else { - std::ptr::null() - } - } - - unsafe extern "C" fn request_restart(host: *const clap_host) { - check_null_ptr!(host, (*host).host_data); - let (instance, _) = InstanceState::from_clap_host_ptr(host); - - // This flag will be reset at the start of one of the `ProcessingTest::run*` functions, and - // in the multi-iteration run function it will trigger a deactivate->reactivate cycle - log::trace!("'clap_host::request_restart()' was called by the plugin, setting the flag"); - instance.requested_restart.store(true, Ordering::SeqCst); - } - - unsafe extern "C" fn request_process(host: *const clap_host) { - check_null_ptr!(host, (*host).host_data); - - // Handling this within the context of the validator would be a bit messy. Do plugins use - // this? - log::debug!("TODO: Handle 'clap_host::request_process()'"); - } - - unsafe extern "C" fn request_callback(host: *const clap_host) { - check_null_ptr!(host, (*host).host_data); - let (instance, this) = InstanceState::from_clap_host_ptr(host); - - // This this is either handled by `handle_callbacks_blocking()` while the audio thread is - // active, or by an explicit call to `handle_callbacks_once()`. We print a warning if the - // callback is not handled before the plugin is destroyed. - log::trace!("'clap_host::request_callback()' was called by the plugin, setting the flag"); - instance.requested_callback.store(true, Ordering::SeqCst); - this.callback_task_sender.send(CallbackTask::Poll).unwrap(); - } - - unsafe extern "C" fn ext_audio_ports_is_rescan_flag_supported( - host: *const clap_host, - _flag: u32, - ) -> bool { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_audio_ports::is_rescan_flag_supported()"); - log::debug!("TODO: Handle 'clap_host_audio_ports::is_rescan_flag_supported()'"); - - true - } - - unsafe extern "C" fn ext_audio_ports_rescan(host: *const clap_host, _flags: u32) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - // TODO: A couple of these flags are only allowed when the plugin is not activated, make - // sure to check for this when implementing this functionality - this.assert_main_thread("clap_host_audio_ports::rescan()"); - log::debug!("TODO: Handle 'clap_host_audio_ports::rescan()'"); - } - - unsafe extern "C" fn ext_note_ports_supported_dialects( - host: *const clap_host, - ) -> clap_note_dialect { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_note_ports::supported_dialects()"); - - CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI | CLAP_NOTE_DIALECT_MIDI_MPE - } - - unsafe extern "C" fn ext_note_ports_rescan(host: *const clap_host, _flags: u32) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_note_ports::rescan()"); - log::debug!("TODO: Handle 'clap_host_note_ports::rescan()'"); - } - - unsafe extern "C" fn ext_preset_load_on_error( - host: *const clap_host, - location_kind: clap_preset_discovery_location_kind, - location: *const c_char, - load_key: *const c_char, - os_error: i32, - msg: *const c_char, - ) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_preset_load::on_error()"); - - let location = LocationValue::new(location_kind, location) - .context("'clap_host_preset_load::on_error()' called with invalid location parameters"); - let load_key = unsafe { util::cstr_ptr_to_optional_string(load_key) }.context( - "'clap_host_preset_load::on_error()' called with an invalid load_key parameter", - ); - let msg = unsafe { util::cstr_ptr_to_mandatory_string(msg) } - .context("'clap_host_preset_load::on_error()' called with an invalid msg parameter"); - match (location, load_key, msg) { - (Ok(location), Ok(Some(load_key)), Ok(msg)) => { - this.set_callback_error(format!( - "'clap_host_preset_load::on_error()' called for {location} with load key \ - {load_key}, OS error code {os_error}, and the following error message: {msg}" - )); - } - (Ok(location), Ok(None), Ok(msg)) => { - this.set_callback_error(format!( - "'clap_host_preset_load::on_error()' called for {location} with no load key, \ - OS error code {os_error}, and the following error message: {msg}" - )); - } - (Err(err), _, _) | (_, Err(err), _) | (_, _, Err(err)) => { - this.set_callback_error(format!("{err:#}")); - } - } - } - - unsafe extern "C" fn ext_preset_load_loaded( - host: *const clap_host, - location_kind: clap_preset_discovery_location_kind, - location: *const c_char, - load_key: *const c_char, - ) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_preset_load::loaded()"); - - let location = LocationValue::new(location_kind, location) - .context("'clap_host_preset_load::loaded()' called with invalid location parameters"); - let load_key = unsafe { util::cstr_ptr_to_optional_string(load_key) } - .context("'clap_host_preset_load::loaded()' called with an invalid load_key parameter"); - match (location, load_key) { - (Ok(_location), Ok(_load_key)) => { - log::debug!("TODO: Handle 'clap_host_preset_load::loaded()'"); - } - (Err(err), _) | (_, Err(err)) => { - this.set_callback_error(format!("{err:#}")); - } - } - } - - unsafe extern "C" fn ext_params_rescan( - host: *const clap_host, - _flags: clap_param_rescan_flags, - ) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_params::rescan()"); - log::debug!("TODO: Handle 'clap_host_params::rescan()'"); - } - - unsafe extern "C" fn ext_params_clear( - host: *const clap_host, - _param_id: clap_id, - _flags: clap_param_clear_flags, - ) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_params::clear()"); - log::debug!("TODO: Handle 'clap_host_params::clear()'"); - } - - unsafe extern "C" fn ext_params_request_flush(host: *const clap_host) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_not_audio_thread("clap_host_params::request_flush()"); - log::debug!("TODO: Handle 'clap_host_params::request_flush()'"); - } - - unsafe extern "C" fn ext_state_mark_dirty(host: *const clap_host) { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.assert_main_thread("clap_host_state::mark_dirty()"); - log::debug!("TODO: Handle 'clap_host_state::mark_dirty()'"); - } - - unsafe extern "C" fn ext_thread_check_is_main_thread(host: *const clap_host) -> bool { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - std::thread::current().id() == this.main_thread_id - } - - unsafe extern "C" fn ext_thread_check_is_audio_thread(host: *const clap_host) -> bool { - check_null_ptr!(host, (*host).host_data); - let (_, this) = InstanceState::from_clap_host_ptr(host); - - this.is_audio_thread(std::thread::current().id()) - } -} diff --git a/src/plugin/index.rs b/src/plugin/index.rs new file mode 100644 index 0000000..3cffc1a --- /dev/null +++ b/src/plugin/index.rs @@ -0,0 +1,236 @@ +//! Utilities and data structures for indexing plugins and presets. + +use crate::cli::sandbox::SandboxOperation; +use crate::plugin::library::PluginMetadata; +use crate::plugin::preset_discovery::{LocationValue, PresetFile, Soundpack}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use walkdir::{DirEntry, WalkDir}; + +/// The separator for path environment variables. +#[cfg(unix)] +const PATH_SEPARATOR: char = ':'; +/// The separator for path environment variables. +#[cfg(windows)] +const PATH_SEPARATOR: char = ';'; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ScannedLibrary { + pub version: (u32, u32, u32), + pub plugins: Vec, + pub preset_providers: Vec, +} + +/// Preset information declared by a preset provider. +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct ScannedPresets { + /// The preset provider's ID. + pub provider_id: String, + /// The preset provider's name. + pub provider_name: String, + /// The preset provider's vendor. + pub provider_vendor: Option, + /// The preset provider's version. + pub provider_version: (u32, u32, u32), + // All sound packs declared by the plugin. + pub soundpacks: Vec, + // All presets declared by the plugin, indexed by their location. Represented by a tuple list + // because JSON object keys must be strings, and with the change from URIs to a location + // kind+value that's not longer the case. + pub presets: Vec<(LocationValue, PresetFile)>, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "status")] +pub enum ScanStatus { + Success { + #[serde(flatten)] + library: ScannedLibrary, + duration: Duration, + }, + Error { + details: String, + }, + Crashed { + details: String, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SandboxedScanLibrary { + pub library_path: PathBuf, + pub scan_presets: bool, +} + +impl SandboxOperation for SandboxedScanLibrary { + const ID: &'static str = "scan-library"; + + type Result = ScanStatus; + + fn run(&self) -> Self::Result { + let start = std::time::Instant::now(); + match scan_library(&self.library_path, self.scan_presets) { + Ok(library) => ScanStatus::Success { + library, + duration: start.elapsed(), + }, + Err(err) => ScanStatus::Error { + details: format!("{:#}", err), + }, + } + } +} + +/// Load the CLAP plugin at `plugin_path`, read plugin metadata, and optionally scan for presets. +pub fn scan_library(plugin_path: &Path, scan_presets: bool) -> Result { + let library = crate::plugin::library::PluginLibrary::load(plugin_path)?; + let metadata = library.metadata()?; + + let presets = if scan_presets && let Ok(preset_discovery_factory) = library.preset_discovery_factory() { + let metadata = preset_discovery_factory + .metadata() + .context("Could not get the preset discovery's provider descriptors")?; + + let mut index = Vec::new(); + for provider_metadata in metadata { + let provider = preset_discovery_factory + .create_provider(&provider_metadata) + .with_context(|| format!("Could not create the provider with ID '{}'", provider_metadata.id))?; + + let declared_data = provider.declared_data(); + let mut presets = BTreeMap::new(); + for location in &declared_data.locations { + presets.extend(provider.crawl_location(location).with_context(|| { + format!( + "Error occurred while crawling presets for the location '{}' with {} using provider '{}' with \ + ID '{}'", + location.name, location.value, provider_metadata.name, provider_metadata.id, + ) + })?); + } + + index.push(ScannedPresets { + provider_id: provider_metadata.id, + provider_name: provider_metadata.name, + provider_vendor: provider_metadata.vendor, + provider_version: provider_metadata.version, + soundpacks: declared_data.soundpacks.clone(), + presets: presets.into_iter().collect(), + }); + } + + index + } else { + vec![] + }; + + Ok(ScannedLibrary { + version: metadata.version, + plugins: metadata.plugins, + preset_providers: presets, + }) +} + +/// Index all installed CLAP plugins by searching the standard directories. Returns a list of +/// paths to all found plugins, or an error if the directories could not be determined. +/// +/// This does not load or validate the plugins in any way. +pub fn index_plugins() -> Result> { + let mut plugins = vec![]; + + let directories = clap_directories().context("Could not find the CLAP plugin locations")?; + for directory in directories { + for clap_plugin_path in walk_clap_plugins(&directory) { + plugins.push(clap_plugin_path.into_path()); + } + } + + Ok(plugins) +} + +/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an +/// error if the paths could not be parsed correctly. +/// +/// While not part of the specification, the Linux paths are also used on the BSDs. +#[cfg(all(target_family = "unix", not(target_os = "macos")))] +pub fn clap_directories() -> Result> { + let home_dir = std::env::var("HOME").context("'$HOME' is not set")?; + + let mut directories = clap_env_path_directories(); + directories.push(Path::new(&home_dir).join(".clap")); + directories.push(PathBuf::from("/usr/lib/clap")); + + Ok(directories) +} + +/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an +/// error if the paths could not be parsed correctly. +#[cfg(target_os = "macos")] +pub fn clap_directories() -> Result> { + let home_dir = std::env::var("HOME").context("'$HOME' is not set")?; + + let mut directories = clap_env_path_directories(); + directories.push(Path::new(&home_dir).join("Library/Audio/Plug-Ins/CLAP")); + directories.push(PathBuf::from("/Library/Audio/Plug-Ins/CLAP")); + + Ok(directories) +} + +/// Get the platform-specific CLAP directories. This takes `$CLAP_PATH` into account. Returns an +/// error if the paths could not be parsed correctly. +#[cfg(windows)] +pub fn clap_directories() -> Result> { + let common_files = std::env::var("COMMONPROGRAMFILES").context("'$COMMONPROGRAMFILES' is not set")?; + let local_appdata = std::env::var("LOCALAPPDATA").context("'$LOCALAPPDATA' is not set")?; + + // TODO: Does this work reliably? There are dedicated Win32 API functions for getting these + // directories, but I'd rather avoid adding a dependency just for that. + let mut directories = clap_env_path_directories(); + directories.push(Path::new(&common_files).join("CLAP")); + directories.push(Path::new(&local_appdata).join("Programs/Common/CLAP")); + + Ok(directories) +} + +/// Parse `$CLAP_PATH` by splitting on on colons. This will return an empty Vec if the environment +/// variable is not set. +fn clap_env_path_directories() -> Vec { + std::env::var("CLAP_PATH") + .map(|clap_path| clap_path.split(PATH_SEPARATOR).map(PathBuf::from).collect()) + .unwrap_or_else(|_| Vec::new()) +} + +/// Return an iterator over all `.clap` plugins under `directory`. These will be files on Linux and +/// Windows, and (bundle) directories on macOS. +fn walk_clap_plugins(directory: &Path) -> impl Iterator { + WalkDir::new(directory) + .min_depth(1) + .follow_links(true) + .same_file_system(false) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| is_clap_plugin(entry.path())) +} + +fn is_clap_plugin(path: &Path) -> bool { + if path.extension().is_some_and(|ext| ext == "clap") { + return false; + } + + let path = match std::fs::canonicalize(path) { + Ok(path) => path, + Err(_) => return false, + }; + + if cfg!(target_os = "macos") { + path.is_dir() + } else { + path.is_file() + } +} diff --git a/src/plugin/instance.rs b/src/plugin/instance.rs index 466d169..30c4d0f 100644 --- a/src/plugin/instance.rs +++ b/src/plugin/instance.rs @@ -1,291 +1,103 @@ //! Abstractions for single CLAP plugin instances for main thread interactions. -use anyhow::Result; -use clap_sys::factory::plugin_factory::clap_plugin_factory; -use clap_sys::plugin::clap_plugin; -use std::ffi::CStr; -use std::marker::PhantomData; -use std::ops::Deref; -use std::pin::Pin; -use std::ptr::NonNull; -use std::rc::Rc; -use std::sync::Arc; +mod audio_thread; +mod main_thread; +mod shared; -use super::ext::Extension; -use super::library::{PluginLibrary, PluginMetadata}; -use super::{assert_plugin_state_eq, assert_plugin_state_initialized}; -use crate::plugin::host::{CallbackTask, Host, InstanceState}; -use crate::util::unsafe_clap_call; -use audio_thread::PluginAudioThread; +pub use audio_thread::*; +pub use main_thread::*; +pub use shared::*; -pub mod audio_thread; -pub mod process; +use crate::plugin::ext::params::ParamsRescan; -/// A `Send+Sync` wrapper around `*const clap_plugin`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[repr(transparent)] -pub struct PluginHandle(pub NonNull); +/// An event generated by plugin->host callbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CallbackEvent { + /// clap_plugin::request_process() + RequestProcess, -unsafe impl Send for PluginHandle {} -unsafe impl Sync for PluginHandle {} + /// clap_plugin_params::request_flush() + RequestFlush, -/// A CLAP plugin instance. The plugin will be deinitialized when this object is dropped. All -/// functions here are callable only from the main thread. Use the -/// [`on_audio_thread()`][Self::on_audio_thread()] method to spawn an audio thread. -/// -/// All functions on `Plugin` and the objects created from it will panic if the plugin is not in the -/// correct state. -#[derive(Debug)] -pub struct Plugin<'lib> { - handle: PluginHandle, - /// Information about this plugin instance stored on the host. This keeps track of things like - /// audio thread IDs, whether the plugin has pending callbacks, and what state it is in. - pub state: Pin>, + /// clap_plugin_params::rescan() + ParamsRescan(ParamsRescan), - /// The CLAP plugin library this plugin instance was created from. This field is not used - /// directly, but keeping a reference to the library here prevents the plugin instance from - /// outliving the library. - _library: &'lib PluginLibrary, - /// To honor CLAP's thread safety guidelines, the thread this object was created from is - /// designated the 'main thread', and this object cannot be shared with other threads. The - /// [`on_audio_thread()`][Self::on_audio_thread()] method spawns an audio thread that is able to call - /// the plugin's audio thread functions. - _send_sync_marker: PhantomData<*const ()>, + AudioPortsRescanNames, + AudioPortsRescanInfo, + AudioPortsRescanList, + + NotePortsRescanNames, + NotePortsRescanAll, + + AudioPortsConfigRescan, + + /// clap_plugin_latency::changed() + LatencyChanged, + + /// clap_plugin_tail::changed() + TailChanged, + + /// clap_plugin_voice_info::changed() + VoiceInfoChanged, + + /// clap_plugin_state::mark_dirty() + StateMarkDirty, } /// The plugin's current lifecycle state. This is checked extensively to ensure that the plugin is /// in the correct state, and things like double activations can't happen. `Plugin` and /// `PluginAudioThread` will drop down to the previous state automatically when the object is -/// dropped and the stop processing or deactivate functions have not yet been calle.d +/// dropped and the stop processing or deactivate functions have not yet been called #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PluginStatus { #[default] Uninitialized, + Initializing, Deactivated, + Activating, Activated, Processing, } -/// An unsafe `Send` wrapper around [`Plugin`], needed to create the audio thread abstraction since -/// we artifically imposed `!Send`+`!Sync` on `Plugin` using the phantomdata marker. -struct PluginSendWrapper<'lib>(*const Plugin<'lib>); - -unsafe impl<'lib> Send for PluginSendWrapper<'lib> {} - -/// This `Deref` wrapper works around the !Sync check check we would interwise run into if we -/// accessed the struct's value directly. -impl<'lib> Deref for PluginSendWrapper<'lib> { - type Target = *const Plugin<'lib>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Drop for Plugin<'_> { - fn drop(&mut self) { - // Make sure the plugin is in the correct state before it gets destroyed - match self.status() { - PluginStatus::Uninitialized | PluginStatus::Deactivated => (), - PluginStatus::Activated => self.deactivate(), - status @ PluginStatus::Processing => panic!( - "The plugin was in an invalid state '{status:?}' when the instance got dropped, \ - this is a clap-validator bug" - ), +impl PluginStatus { + #[track_caller] + pub fn assert_is(&self, expected: PluginStatus) { + if *self != expected { + panic!( + "Invalid plugin function call while the plugin is in an incorrect state ({:?}, must be {:?})", + self, expected + ) } - - // TODO: We can't handle host callbacks that happen in between these two functions, but the - // plugin really shouldn't be making callbacks in deactivate() - let plugin = self.as_ptr(); - unsafe_clap_call! { plugin=>destroy(plugin) }; - - self.host().unregister_instance(self.state.clone()); } -} -impl<'lib> Plugin<'lib> { - /// Create a plugin instance and return the still uninitialized plugin. Returns an error if the - /// plugin could not be created. The plugin instance will be registered with the host, and - /// unregistered when this object is dropped again. - pub fn new( - library: &'lib PluginLibrary, - host: Rc, - factory: &clap_plugin_factory, - plugin_id: &CStr, - ) -> Result { - // The host can use this to keep track of things like audio threads and pending callbacks. - // The instance is remvoed again when this object is dropped. - let state = InstanceState::new(host.clone()); - let plugin = unsafe_clap_call! { - factory=>create_plugin(factory, state.clap_host_ptr(), plugin_id.as_ptr()) - }; - if plugin.is_null() { - anyhow::bail!( - "'clap_plugin_factory::create_plugin({plugin_id:?})' returned a null pointer." - ); + #[track_caller] + pub fn assert_is_not(&self, unexpected: PluginStatus) { + if *self == unexpected { + panic!( + "Invalid plugin function call while the plugin is in an incorrect state ({:?}, must not be {:?})", + self, unexpected + ) } - - // We can only register the plugin instance with the host now because we did not have a - // plugin pointer before this. - let handle = PluginHandle(NonNull::new(plugin as *mut clap_plugin).unwrap()); - state.plugin.store(Some(handle)); - host.register_instance(state.clone()); - - Ok(Plugin { - handle, - state, - - _library: library, - _send_sync_marker: PhantomData, - }) - } - - /// Get the raw pointer to the `clap_plugin` instance. - pub fn as_ptr(&self) -> *const clap_plugin { - self.handle.0.as_ptr() } - /// Get this plugin's metadata descriptor. In theory this should be the same as the one - /// retrieved from the factory earlier. - pub fn descriptor(&self) -> Result { - let plugin = self.as_ptr(); - let descriptor = unsafe { (*plugin).desc }; - if descriptor.is_null() { - anyhow::bail!("The 'desc' field on the 'clap_plugin' struct is a null pointer."); + #[track_caller] + pub fn assert_active(&self) { + if *self < PluginStatus::Activated { + panic!( + "Invalid plugin function call while the plugin is in an incorrect state ({:?}, must be activated)", + self + ) } - - PluginMetadata::from_descriptor(unsafe { &*descriptor }) - } - - /// Get the host for this plugin instance. - pub fn host(&self) -> &Host { - // `Plugin` can only be used from the main thread - self.state - .host() - .expect("Tried to get the host instance from a thread that isn't the main thread") - } - - /// The plugin's current initialization status. - pub fn status(&self) -> PluginStatus { - self.state.status.load() } - /// Get the _main thread_ extension abstraction for the extension `T`, if the plugin supports - /// this extension. Returns `None` if it does not. The plugin needs to be initialized using - /// [`init()`][Self::init()] before this may be called. - pub fn get_extension<'a, T: Extension<&'a Self>>(&'a self) -> Option { - assert_plugin_state_initialized!(self); - - let plugin = self.as_ptr(); - let extension_ptr = unsafe_clap_call! { - plugin=>get_extension(plugin, T::EXTENSION_ID.as_ptr()) - }; - - if extension_ptr.is_null() { - None - } else { - Some(T::new( - self, - NonNull::new(extension_ptr as *mut T::Struct).unwrap(), - )) - } - } - - /// Execute some code for this plugin from an audio thread context. The closure receives a - /// [`PluginAudioThread`], which disallows calling main thread functions, and permits calling - /// audio thread functions. - /// - /// If whatever happens on the audio thread caused main-thread callback requests to be emited, - /// then those will be handled concurrently. - pub fn on_audio_thread<'a, T: Send, F: FnOnce(PluginAudioThread<'a>) -> T + Send>( - &'a self, - f: F, - ) -> T { - assert_plugin_state_eq!(self, PluginStatus::Activated); - - crossbeam::scope(|s| { - let unsafe_self_wrapper = PluginSendWrapper(self); - let callback_task_sender = self.host().callback_task_sender.clone(); - - let audio_thread = s - .builder() - .name(String::from("audio-thread")) - .spawn(move |_| { - // SAFETY: We artificially impose `!Send`+`!Sync` requirements on `Plugin` and - // `PluginAudioThread` to prevent them from being shared with other - // threads. But we'll need to temporarily lift that restriction in order - // to create this `PluginAudioThread`. - let this = unsafe { &**unsafe_self_wrapper }; - - // The host may use this to assert that calls are run from an audio thread - this.state - .audio_thread - .store(Some(std::thread::current().id())); - let result = f(PluginAudioThread::new(this)); - this.state.audio_thread.store(None); - - // The main thread should unblock when the audio thread is done - callback_task_sender.send(CallbackTask::Stop).unwrap(); - - result - }) - .expect("Unable to spawn an audio thread"); - - // Handle callbacks requests on the main thread whle the aduio thread is running - self.host().handle_callbacks_blocking(); - - audio_thread.join().expect("Audio thread panicked") - }) - .expect("Audio thread panicked") - } - - /// Initialize the plugin. This needs to be called before doing anything else. - pub fn init(&self) -> Result<()> { - assert_plugin_state_eq!(self, PluginStatus::Uninitialized); - - let plugin = self.as_ptr(); - if unsafe_clap_call! { plugin=>init(plugin) } { - self.state.status.store(PluginStatus::Deactivated); - Ok(()) - } else { - anyhow::bail!("'clap_plugin::init()' returned false.") - } - } - - /// Activate the plugin. Returns an error if the plugin returned `false`. See - /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the - /// preconditions. - pub fn activate( - &self, - sample_rate: f64, - min_buffer_size: usize, - max_buffer_size: usize, - ) -> Result<()> { - assert_plugin_state_eq!(self, PluginStatus::Deactivated); - - // Apparently 0 is invalid here - assert!(min_buffer_size >= 1); - - let plugin = self.as_ptr(); - if unsafe_clap_call! { - plugin=>activate(plugin, sample_rate, min_buffer_size as u32, max_buffer_size as u32) - } { - self.state.status.store(PluginStatus::Activated); - Ok(()) - } else { - anyhow::bail!("'clap_plugin::activate()' returned false.") + #[track_caller] + pub fn assert_inactive(&self) { + if *self >= PluginStatus::Activated { + panic!( + "Invalid plugin function call while the plugin is in an incorrect state ({:?}, must be deactivated)", + self + ) } } - - /// Deactivate the plugin. See - /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the - /// preconditions. - pub fn deactivate(&self) { - assert_plugin_state_eq!(self, PluginStatus::Activated); - - let plugin = self.as_ptr(); - unsafe_clap_call! { plugin=>deactivate(plugin) }; - - self.state.status.store(PluginStatus::Deactivated); - } } diff --git a/src/plugin/instance/audio_thread.rs b/src/plugin/instance/audio_thread.rs index 3f48f7d..8d437f6 100644 --- a/src/plugin/instance/audio_thread.rs +++ b/src/plugin/instance/audio_thread.rs @@ -1,40 +1,44 @@ //! Abstractions for single CLAP plugin instances for audio thread interactions. +use super::{Plugin, PluginStatus}; +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::{CallbackEvent, PluginShared}; +use crate::plugin::process::{InputEventQueue, OutputEventQueue}; +use crate::plugin::util::{Proxy, clap_call}; use anyhow::Result; +use clap_sys::audio_buffer::clap_audio_buffer; +use clap_sys::events::clap_event_transport; use clap_sys::plugin::clap_plugin; -use clap_sys::process::{ - CLAP_PROCESS_CONTINUE, CLAP_PROCESS_CONTINUE_IF_NOT_QUIET, CLAP_PROCESS_ERROR, - CLAP_PROCESS_SLEEP, CLAP_PROCESS_TAIL, -}; +use clap_sys::process::*; +use std::fmt::Debug; use std::marker::PhantomData; -use std::pin::Pin; -use std::ptr::NonNull; -use std::sync::Arc; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::mpsc::Sender; -use crate::plugin::host::InstanceState; -use crate::util::unsafe_clap_call; - -use super::process::ProcessData; -use super::{assert_plugin_state_eq, assert_plugin_state_initialized}; -use super::{Plugin, PluginStatus}; -use crate::plugin::ext::Extension; +pub type MainThreadTask = Box Result<()> + Send>; /// An audio thread equivalent to [`Plugin`]. This version only allows audio thread functions to be /// called. It can be constructed using [`Plugin::on_audio_thread()`]. -#[derive(Debug)] pub struct PluginAudioThread<'a> { - /// The plugin instance this audio thread belongs to. This is needed to ensure that the audio - /// thread instance cannot outlive the plugin instance (which cannot outlive the plugin - /// library). This `Plugin` also contains a reference to the plugin instance's state. - plugin: &'a Plugin<'a>, - /// To honor CLAP's thread safety guidelines, this audio thread abstraction cannot be shared - /// with or sent to other threads. + /// Information about this plugin instance stored on the host. This keeps track of things like + /// audio thread IDs, whether the plugin has pending callbacks, and what state it is in. + shared: Proxy, + + /// A channel to send tasks to the main thread. + /// Allows for ergonomic access to the main thread OR executing tasks on the main thread in parallel with the audio thread. + sender: Sender, + + _plugin_marker: PhantomData<&'a Plugin<'a>>, + + /// To honor CLAP's thread safety guidelines, the thread this object was created from is + /// designated the 'audio thread', and this object cannot be shared with other threads. _send_sync_marker: PhantomData<*const ()>, } /// The equivalent of `clap_process_status`, minus the `CLAP_PROCESS_ERROR` value as this is already /// treated as an error by `PluginAudioThread::process()`. -#[derive(Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum ProcessStatus { Continue, ContinueIfNotQuiet, @@ -42,66 +46,106 @@ pub enum ProcessStatus { Sleep, } +#[derive(Debug)] +pub struct ProcessInfo<'a> { + pub frames_count: u32, + pub steady_time: Option, + pub transport: Option<&'a clap_event_transport>, + pub audio_inputs: &'a [clap_audio_buffer], + pub audio_outputs: &'a mut [clap_audio_buffer], + pub input_events: &'a Proxy, + pub output_events: &'a Proxy, +} + impl Drop for PluginAudioThread<'_> { fn drop(&mut self) { - match self - .state() - .status - .compare_exchange(PluginStatus::Processing, PluginStatus::Activated) - { - Ok(_) => self.stop_processing(), - Err(PluginStatus::Activated) => (), - Err(state) => panic!( - "The plugin was in an invalid state '{state:?}' when the audio thread got \ - dropped, this is a clap-validator bug" - ), - } + self.shared.audio_thread_id.store(None); } } impl<'a> PluginAudioThread<'a> { - pub fn new(plugin: &'a Plugin) -> Self { + pub(super) fn new(shared: Proxy, sender: Sender) -> PluginAudioThread<'a> { + shared.audio_thread_id.store(Some(std::thread::current().id())); + PluginAudioThread { - plugin, + sender, + shared, + _plugin_marker: PhantomData, _send_sync_marker: PhantomData, } } /// Get the raw pointer to the `clap_plugin` instance. pub fn as_ptr(&self) -> *const clap_plugin { - self.plugin.as_ptr() - } - - /// Get the underlying `Plugin`'s [`InstanceState`] object. - pub fn state(&self) -> &Pin> { - &self.plugin.state + self.shared.clap_plugin } /// Get the plugin's current initialization status. pub fn status(&self) -> PluginStatus { - self.state().status.load() + self.shared.status() + } + + /// Get a reference to the plugin's shared state. + pub fn shared(&self) -> &PluginShared { + &self.shared } /// Get the _audio thread_ extension abstraction for the extension `T`, if the plugin supports - /// this extension. Returns `None` if it does not. The plugin needs to be initialized using - /// [`init()`][Self::init()] before this may be called. - // - // TODO: Remove this unused attribute once we implement audio thread extensions - #[allow(unused)] - pub fn get_extension>(&'a self) -> Option { - assert_plugin_state_initialized!(self); + /// this extension. Returns `None` if it does not. + pub fn get_extension>(&'a self) -> Option { + unsafe { self.shared.raw_extension::().map(|ptr| T::new(self, ptr)) } + } - let plugin = self.as_ptr(); - let extension_ptr = - unsafe_clap_call! { plugin=>get_extension(plugin, T::EXTENSION_ID.as_ptr()) }; + /// Dispatch a task to be executed on the main thread. This is a blocking call that will wait + /// for the task to complete and return its result. + pub fn on_main_thread T + Send, T: Send>(&self, callback: F) -> T { + let (sender, recv) = std::sync::mpsc::sync_channel(0); - if extension_ptr.is_null() { - None + #[allow(clippy::type_complexity)] + let callback: Box Result<()> + Send> = Box::new(move |plugin| { + let result = catch_unwind(AssertUnwindSafe(|| callback(plugin))); + sender.send(result).unwrap(); + Ok(()) + }); + + self.sender + .send(unsafe { + // SAFETY: we just erase the lifetime here, as we guarantee that the callback is valid until Receiver is dropped, at that point the callback has been dropped already. + std::mem::transmute::< + Box Result<()> + Send>, + Box Result<()> + Send + 'static>, + >(callback) + }) + .unwrap(); + + match recv.recv().unwrap() { + Ok(value) => value, + Err(panic) => std::panic::resume_unwind(panic), + } + } + + /// Same as [`Self::on_main_thread`], but does not wait for the result and does not block. + #[allow(unused)] + pub fn send_main_thread Result<()> + Send + 'static>(&self, callback: F) { + self.sender.send(Box::new(callback)).unwrap(); + } + + /// Process pending callbacks. + pub fn poll_callback_with(&self, mut f: impl FnMut(&Plugin, CallbackEvent) -> Result<()> + Send) -> Result<()> { + if self.shared.requested_callback.load() { + self.on_main_thread(move |plugin| plugin.poll_callback(|event| f(plugin, event))) } else { - Some(T::new( - self, - NonNull::new(extension_ptr as *mut T::Struct).unwrap(), - )) + Ok(()) + } + } + + /// Process pending callbacks, ignoring the callback events. Does not block. + pub fn poll_callback(&self) { + if self.shared.requested_callback.load() { + self.send_main_thread(move |plugin| { + plugin.poll_callback_unchecked(); + Ok(()) + }); } } @@ -109,11 +153,17 @@ impl<'a> PluginAudioThread<'a> { /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the /// preconditions. pub fn start_processing(&self) -> Result<()> { - assert_plugin_state_eq!(self, PluginStatus::Activated); + self.status().assert_is(PluginStatus::Activated); + + let span = Span::begin("clap_plugin::start_processing", ()); + let result = unsafe { + clap_call! { self.as_ptr()=>start_processing(self.as_ptr()) } + }; - let plugin = self.as_ptr(); - if unsafe_clap_call! { plugin=>start_processing(plugin) } { - self.state().status.store(PluginStatus::Processing); + span.finish(record!(result: result)); + + if result { + self.shared.set_status(PluginStatus::Processing); Ok(()) } else { anyhow::bail!("'clap_plugin::start_processing()' returned false.") @@ -124,38 +174,133 @@ impl<'a> PluginAudioThread<'a> { /// status code, then this will return an error. See /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the /// preconditions. - pub fn process(&self, process_data: &mut ProcessData) -> Result { - assert_plugin_state_eq!(self, PluginStatus::Processing); + pub fn process(&self, process: ProcessInfo) -> Result { + self.status().assert_is(PluginStatus::Processing); - let plugin = self.as_ptr(); - let result = process_data.with_clap_process_data(|clap_process_data| { - unsafe_clap_call! { plugin=>process(plugin, &clap_process_data) } - }); + self.shared.is_currently_in_process_call.store(true); - match result { - CLAP_PROCESS_ERROR => anyhow::bail!( - "The plugin returned 'CLAP_PROCESS_ERROR' from 'clap_plugin::process()'." - ), - CLAP_PROCESS_CONTINUE => Ok(ProcessStatus::Continue), - CLAP_PROCESS_CONTINUE_IF_NOT_QUIET => Ok(ProcessStatus::ContinueIfNotQuiet), - CLAP_PROCESS_TAIL => Ok(ProcessStatus::Tail), - CLAP_PROCESS_SLEEP => Ok(ProcessStatus::Sleep), + let span = Span::begin("clap_plugin::process", &process); + + let result = unsafe { + clap_call! { self.as_ptr()=>process(self.as_ptr(), &clap_process { + frames_count: process.frames_count, + steady_time: process.steady_time.map(|t| t as i64).unwrap_or(-1), + transport: process.transport.map_or(std::ptr::null(), |t| t as *const clap_event_transport), + audio_inputs: process.audio_inputs.as_ptr(), + audio_outputs: process.audio_outputs.as_mut_ptr(), + audio_inputs_count: process.audio_inputs.len() as u32, + audio_outputs_count: process.audio_outputs.len() as u32, + in_events: Proxy::vtable(process.input_events), + out_events: Proxy::vtable(process.output_events), + }) } + }; + + span.finish(record!( + result: match result { + CLAP_PROCESS_ERROR => "CLAP_PROCESS_ERROR", + CLAP_PROCESS_CONTINUE => "CLAP_PROCESS_CONTINUE", + CLAP_PROCESS_CONTINUE_IF_NOT_QUIET => "CLAP_PROCESS_CONTINUE_IF_NOT_QUIET", + CLAP_PROCESS_TAIL => "CLAP_PROCESS_TAIL", + CLAP_PROCESS_SLEEP => "CLAP_PROCESS_SLEEP", + _ => "?", + } + )); + + self.shared.is_currently_in_process_call.store(false); + + Ok(match result { + CLAP_PROCESS_CONTINUE => ProcessStatus::Continue, + CLAP_PROCESS_CONTINUE_IF_NOT_QUIET => ProcessStatus::ContinueIfNotQuiet, + CLAP_PROCESS_TAIL => ProcessStatus::Tail, + CLAP_PROCESS_SLEEP => ProcessStatus::Sleep, + CLAP_PROCESS_ERROR => { + anyhow::bail!("The plugin returned 'CLAP_PROCESS_ERROR' from 'clap_plugin::process()'.") + } result => anyhow::bail!( - "The plugin returned an unknown 'clap_process_status' value {result} from \ - 'clap_plugin::process()'." + "The plugin returned an unknown 'clap_process_status' value {result} from 'clap_plugin::process()'." ), - } + }) + } + + /// Reset the internal state of the plugin. + pub fn reset(&self) { + self.status().assert_active(); + + unsafe { + let _span = Span::begin("clap_plugin::reset", ()); + clap_call! { self.as_ptr()=>reset(self.as_ptr()) } + }; } /// Stop processing audio. See /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the /// preconditions. pub fn stop_processing(&self) { - assert_plugin_state_eq!(self, PluginStatus::Processing); + self.status().assert_is(PluginStatus::Processing); + + unsafe { + let _span = Span::begin("clap_plugin::stop_processing", ()); + clap_call! { self.as_ptr()=>stop_processing(self.as_ptr()) } + }; + + self.shared.set_status(PluginStatus::Activated); + } +} - let plugin = self.as_ptr(); - unsafe_clap_call! { plugin=>stop_processing(plugin) }; +impl Recordable for ProcessInfo<'_> { + fn record(&self, record: &mut dyn Recorder) { + record.record("frames_count", self.frames_count); + record.record("steady_time", self.steady_time.map(|t| t as i64).unwrap_or(-1)); + + if let Some(transport) = self.transport { + record.record("transport", transport); + } - self.state().status.store(PluginStatus::Activated); + for i in 0..self.audio_inputs.len() { + record.record( + &format!("audio_input.{i}.channel_count"), + self.audio_inputs[i].channel_count, + ); + record.record( + &format!("audio_input.{i}.data32"), + format_args!("{:p}", self.audio_inputs[i].data32), + ); + record.record( + &format!("audio_input.{i}.data64"), + format_args!("{:p}", self.audio_inputs[i].data64), + ); + record.record( + &format!("audio_input.{i}.constant_mask"), + format_args!("0b{:b}", self.audio_inputs[i].constant_mask), + ); + record.record(&format!("audio_input.{i}.latency"), self.audio_inputs[i].latency); + } + + for i in 0..self.audio_outputs.len() { + record.record( + &format!("audio_output.{i}.channel_count"), + self.audio_outputs[i].channel_count, + ); + record.record( + &format!("audio_output.{i}.data32"), + format_args!("{:p}", self.audio_outputs[i].data32), + ); + record.record( + &format!("audio_output.{i}.data64"), + format_args!("{:p}", self.audio_outputs[i].data64), + ); + record.record(&format!("audio_output.{i}.latency"), self.audio_outputs[i].latency); + } + } +} + +impl Debug for ProcessStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProcessStatus::Continue => write!(f, "CLAP_PROCESS_CONTINUE"), + ProcessStatus::ContinueIfNotQuiet => write!(f, "CLAP_PROCESS_CONTINUE_IF_NOT_QUIET"), + ProcessStatus::Tail => write!(f, "CLAP_PROCESS_TAIL"), + ProcessStatus::Sleep => write!(f, "CLAP_PROCESS_SLEEP"), + } } } diff --git a/src/plugin/instance/main_thread.rs b/src/plugin/instance/main_thread.rs new file mode 100644 index 0000000..3385b26 --- /dev/null +++ b/src/plugin/instance/main_thread.rs @@ -0,0 +1,255 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::instance::{CallbackEvent, PluginAudioThread, PluginShared, PluginStatus}; +use crate::plugin::library::PluginMetadata; +use crate::plugin::util::{Proxy, clap_call}; +use anyhow::Result; +use clap_sys::plugin::clap_plugin; +use std::marker::PhantomData; +use std::panic::resume_unwind; +use std::sync::mpsc::Receiver; + +/// A CLAP plugin instance. The plugin will be deinitialized when this object is dropped. All +/// functions here are callable only from the main thread. Use the +/// [`on_audio_thread()`][Self::on_audio_thread()] method to spawn an audio thread. +/// +/// All functions on `Plugin` and the objects created from it will panic if the plugin is not in the +/// correct state. +pub struct Plugin<'lib> { + pub(super) callback_receiver: Receiver, + + /// Information about this plugin instance stored on the host. This keeps track of things like + /// audio thread IDs, whether the plugin has pending callbacks, and what state it is in. + pub(super) shared: Proxy, + + /// The CLAP plugin library this plugin instance was created from. This field is not used + /// directly, but keeping a reference to the library here prevents the plugin instance from + /// outliving the library. + pub(super) _library: PhantomData<&'lib ()>, + + /// To honor CLAP's thread safety guidelines, the thread this object was created from is + /// designated the 'main thread', and this object cannot be shared with other threads. The + /// [`on_audio_thread()`][Self::on_audio_thread()] method spawns an audio thread that is able to call + /// the plugin's audio thread functions. + pub(super) _thread: PhantomData<*const ()>, +} + +impl Drop for Plugin<'_> { + fn drop(&mut self) { + if let Some(error) = self.shared.callback_error.lock().unwrap().take() { + log::warn!( + "The validator's host has detected a callback error but this error has not been used as part of the \ + test result. This could be a clap-validator bug. The error message is: {error}" + ) + } + + // Make sure the plugin is in the correct state before it gets destroyed + match self.status() { + PluginStatus::Uninitialized | PluginStatus::Deactivated => (), + status => panic!( + "The plugin was in an invalid state '{status:?}' when the instance got dropped, this is a \ + clap-validator bug" + ), + } + + let plugin = self.as_ptr(); + unsafe { + let _span = Span::begin("clap_plugin::destroy", ()); + clap_call! { plugin=>destroy(plugin) } + } + } +} + +impl<'lib> Plugin<'lib> { + /// Get the raw pointer to the `clap_plugin` instance. + pub fn as_ptr(&self) -> *const clap_plugin { + self.shared.clap_plugin + } + + /// Get this plugin's metadata descriptor. In theory this should be the same as the one + /// retrieved from the factory earlier. + pub fn descriptor(&self) -> Result { + let plugin = self.as_ptr(); + let descriptor = unsafe { (*plugin).desc }; + if descriptor.is_null() { + anyhow::bail!("The 'desc' field on the 'clap_plugin' struct is a null pointer."); + } + + PluginMetadata::from_descriptor(unsafe { &*descriptor }) + } + + /// The plugin's current initialization status. + pub fn status(&self) -> PluginStatus { + self.shared.status() + } + + /// Handle any pending main-thread callbacks for this plugin and pending callback events. + /// Returns an error if a callback error occurred. + pub fn poll_callback(&self, mut f: impl FnMut(CallbackEvent) -> Result<()>) -> Result<()> { + self.poll_callback_unchecked(); + + if let Some(error) = self.shared.callback_error.lock().unwrap().take() { + anyhow::bail!(error); + } + + while let Ok(event) = self.callback_receiver.try_recv() { + f(event)?; + } + + Ok(()) + } + + /// Get the _main thread_ extension abstraction for the extension `T`, if the plugin supports + /// this extension. Returns `None` if it does not. The plugin needs to be initialized using + /// [`init()`][Self::init()] before this may be called. + pub fn get_extension<'a, T: Extension>(&'a self) -> Option { + unsafe { self.shared.raw_extension::().map(|ptr| T::new(self, ptr)) } + } + + /// Execute some code for this plugin from an audio thread context. The closure receives a + /// [`PluginAudioThread`], which disallows calling main thread functions, and permits calling + /// audio thread functions. + /// + /// If whatever happens on the audio thread caused main-thread callback requests to be emited, + /// then those will be handled concurrently. + pub fn on_audio_thread Result + Send>(&self, f: F) -> Result { + if self.shared.audio_thread_id.load().is_some() { + panic!("An audio thread is already running for this plugin instance."); + } + + let (sender, receiver) = std::sync::mpsc::channel(); + + let result = std::thread::scope(|s| { + let shared = self.shared.clone(); + let audio_thread = std::thread::Builder::new() + .name("audio".into()) + .spawn_scoped(s, || f(PluginAudioThread::new(shared, sender))) + .unwrap(); + + let mut error = None; + while let Ok(task) = receiver.recv() { + if let Err(e) = task(self) { + error.get_or_insert(e); + } + } + + if let Some(error) = error { + return Err(error); + } + + audio_thread.join().unwrap_or_else(|e| resume_unwind(e)) + }); + + self.poll_callback_unchecked(); + result + } + + /// Initialize the plugin. This needs to be called before doing anything else. + pub fn init(&self) -> Result<()> { + self.status().assert_is(PluginStatus::Uninitialized); + self.shared.set_status(PluginStatus::Initializing); + + let _span = Span::begin("clap_plugin::init", ()); + let result = unsafe { + clap_call! { self.as_ptr()=>init(self.as_ptr()) } + }; + + if result { + // If the plugin never calls `request_callback`, the validator won't catch this + anyhow::ensure!( + unsafe { (*self.as_ptr()).on_main_thread.is_some() }, + "clap_plugin::on_main_thread is null" + ); + + self.shared.set_status(PluginStatus::Deactivated); + Ok(()) + } else { + self.shared.set_status(PluginStatus::Uninitialized); + anyhow::bail!("'clap_plugin::init()' returned false.") + } + } + + /// Activate the plugin. Returns an error if the plugin returned `false`. See + /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the + /// preconditions. + /// + /// Also checks for 'activate'/'request_restart' loops. + pub fn activate(&self, sample_rate: f64, min_buffer_size: u32, max_buffer_size: u32) -> Result<()> { + self.status().assert_is(PluginStatus::Deactivated); + + // Apparently 0 is invalid here + assert!(min_buffer_size >= 1); + assert!(max_buffer_size >= min_buffer_size); + + for i in (0..10).rev() { + // we need to track the `Activating` state to validate that we call clap_host_latency::changed only within the activation call. + self.shared.set_status(PluginStatus::Activating); + + let result = unsafe { + let span = Span::begin( + "clap_plugin::activate", + record! { + sample_rate: sample_rate, + min_buffer_size: min_buffer_size, + max_buffer_size: max_buffer_size + }, + ); + + let result = clap_call! { self.as_ptr()=>activate(self.as_ptr(), sample_rate, min_buffer_size, max_buffer_size) }; + span.finish(record!(result: result)); + result + }; + + if result { + self.shared.set_status(PluginStatus::Activated); + } else { + self.shared.set_status(PluginStatus::Deactivated); + anyhow::bail!("'clap_plugin::activate()' returned false.") + } + + if self.shared.requested_restart.swap(false) { + if i == 0 { + anyhow::bail!("The plugin seems to be stuck in an 'activate'/'request_restart' loop"); + } else { + self.deactivate(); + continue; + } + } + + return Ok(()); + } + + Ok(()) + } + + /// Deactivate the plugin. See + /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for the + /// preconditions. + pub fn deactivate(&self) { + self.status().assert_is(PluginStatus::Activated); + + unsafe { + let _span = Span::begin("clap_plugin::deactivate", ()); + clap_call! { self.as_ptr()=>deactivate(self.as_ptr()) } + } + + self.shared.set_status(PluginStatus::Deactivated); + } + + /// Same as [`poll_callback()`][Self::poll_callback()] but does not check for callback errors, and does not process callback events. + pub fn poll_callback_unchecked(&self) { + // 10 iterations, then bail + for _ in 0..10 { + if !self.shared.requested_callback.swap(false) { + return; + } + + unsafe { + let _span = Span::begin("clap_plugin::on_main_thread", ()); + clap_call! { self.as_ptr()=>on_main_thread(self.as_ptr()) } + }; + } + + log::warn!("The plugin seems to be stuck in an 'on_main_thread' callback loop"); + } +} diff --git a/src/plugin/instance/process.rs b/src/plugin/instance/process.rs deleted file mode 100644 index 4f109ab..0000000 --- a/src/plugin/instance/process.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! Data structures and functions surrounding audio processing. - -use anyhow::Result; -use clap_sys::audio_buffer::clap_audio_buffer; -use clap_sys::events::{ - clap_event_header, clap_event_midi, clap_event_note, clap_event_note_expression, - clap_event_param_mod, clap_event_param_value, clap_event_transport, clap_input_events, - clap_output_events, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_CHOKE, - CLAP_EVENT_NOTE_END, CLAP_EVENT_NOTE_EXPRESSION, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, - CLAP_EVENT_PARAM_MOD, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT, - CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, - CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE, CLAP_TRANSPORT_IS_PLAYING, -}; -use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR}; -use clap_sys::process::clap_process; -use parking_lot::Mutex; -use rand::Rng; -use rand_pcg::Pcg32; -use std::ffi::c_void; -use std::pin::Pin; - -use crate::util::check_null_ptr; - -/// The input and output data for a call to `clap_plugin::process()`. -pub struct ProcessData<'a> { - /// The input and output audio buffers. - pub buffers: &'a mut AudioBuffers<'a>, - /// The input events. - pub input_events: Pin>>, - /// The output events. - pub output_events: Pin>>, - - config: ProcessConfig, - /// The current transport information. This is populated when constructing this object, and the - /// transport can be advanced `N` samples using the - /// [`advance_transport()`][Self::advance_transport()] method. - transport_info: clap_event_transport, - /// The current sample position. This is used to recompute values in `transport_info`. - sample_pos: u32, - // TODO: Maybe do something with `steady_time` -} - -/// The general context information for a process call. -#[derive(Debug, Clone, Copy)] -pub struct ProcessConfig { - /// The current sample rate. - pub sample_rate: f64, - // The current tempo in beats per minute. - pub tempo: f64, - // The time signature's numerator. - pub time_sig_numerator: u16, - // The time signature's denominator. - pub time_sig_denominator: u16, -} - -/// Audio buffers for [`ProcessData`]. CLAP allows hosts to do both in-place and out-of-place -/// processing, so we'll support and test both methods. -pub enum AudioBuffers<'a> { - /// Out-of-place processing with separate non-aliasing input and output buffers. - OutOfPlace(OutOfPlaceAudioBuffers<'a>), - // TODO: In-place processing, figure out a safe abstraction for this if the in-place pairs - // aren't symmetrical between the inputs and outputs (e.g. when it's not just - // input1<->output1, input2<->output2, etc.). -} - -/// Audio buffers for out-of-place processing. This wrapper allocates and sets up the channel -/// pointers. To avoid an unnecessary level of abstraction where the `Vec>`s need to be -/// converted to a slice of slices, this data structure borrows the vectors directly. -// -// TODO: This only does f32 for now, we'll also want to test f64 and mixed configurations later. -pub struct OutOfPlaceAudioBuffers<'a> { - // These are all indexed by `[port_idx][channel_idx][sample_idx]`. The inputs also need to be - // mutable because reborrwing them from here is the only way to modify them without - // reinitializing the pointers. - inputs: &'a mut [Vec>], - outputs: &'a mut [Vec>], - - // These are point to `inputs` and `outputs` because `clap_audio_buffer` needs to contain a - // `*const *const f32` - _input_channel_pointers: Vec>, - _output_channel_pointers: Vec>, - clap_inputs: Vec, - clap_outputs: Vec, - - /// The number of samples for this buffer. This is consistent across all inner vectors. - num_samples: usize, -} - -// SAFETY: Sharing these pointers with other threads is safe as they refer to the borrowed input and -// output slices. The pointers thus cannot be invalidated. -unsafe impl Send for OutOfPlaceAudioBuffers<'_> {} -unsafe impl Sync for OutOfPlaceAudioBuffers<'_> {} - -/// An event queue that can be used as either an input queue or an output queue. This is always -/// allocated through a `Pin>` so the pointers are stable. The `VTable` type -/// argument should be either `clap_input_events` or `clap_output_events`. -// -// TODO: There's not much benefit in having this be generic over the VTable and it makes it more -// dififcult to reason about. Just split this up into two concrete structs. -#[derive(Debug)] -pub struct EventQueue { - /// The vtable for this event queue. This will be either `clap_input_events` or - /// `clap_output_events`. - vtable: VTable, - /// The actual event queue. Since we're going for correctness over performance, this uses a very - /// suboptimal memory layout by just using an `enum` instead of doing fancy bit packing. - pub events: Mutex>, -} - -/// An event sent to or from the plugin. This uses an enum to make the implementation simple and -/// correct at the cost of more wasteful memory usage. -#[derive(Debug, Clone)] -#[repr(C, align(8))] -pub enum Event { - /// `CLAP_EVENT_NOTE_ON`, `CLAP_EVENT_NOTE_OFF`, `CLAP_EVENT_NOTE_CHOKE`, or `CLAP_EVENT_NOTE_END`. - Note(clap_event_note), - /// `CLAP_EVENT_NOTE_EXPRESSION`. - NoteExpression(clap_event_note_expression), - /// `CLAP_EVENT_MIDI`. - Midi(clap_event_midi), - /// `CLAP_EVENT_PARAM_VALUE`. - ParamValue(clap_event_param_value), - /// `CLAP_EVENT_PARAM_MOD`. - ParamMod(clap_event_param_mod), - /// An unhandled event type. This is only used when the plugin outputs an event we don't handle - /// or recognize. - Unknown(clap_event_header), -} - -impl Default for ProcessConfig { - fn default() -> Self { - Self { - sample_rate: 44_100.0, - tempo: 110.0, - time_sig_numerator: 4, - time_sig_denominator: 4, - } - } -} - -impl<'a> ProcessData<'a> { - /// Initialize the process data using the given audio buffers. The transport information will be - /// initialized at the start of the project, and it can be moved using the - /// [`advance_transport()`][Self::advance_transport()] method. - // - // TODO: More transport info options. Missing fields, loop regions, flags, etc. - pub fn new(buffers: &'a mut AudioBuffers<'a>, config: ProcessConfig) -> Self { - ProcessData { - buffers, - input_events: EventQueue::new_input(), - output_events: EventQueue::new_output(), - - config, - transport_info: clap_event_transport { - header: clap_event_header { - size: std::mem::size_of::() as u32, - time: 0, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_TRANSPORT, - flags: 0, - }, - flags: CLAP_TRANSPORT_HAS_TEMPO - | CLAP_TRANSPORT_HAS_BEATS_TIMELINE - | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE - | CLAP_TRANSPORT_HAS_TIME_SIGNATURE - | CLAP_TRANSPORT_IS_PLAYING, - song_pos_beats: 0, - song_pos_seconds: 0, - tempo: config.tempo, - tempo_inc: 0.0, - // These four currently aren't used - loop_start_beats: 0, - loop_end_beats: 0, - loop_start_seconds: 0, - loop_end_seconds: 0, - bar_start: 0, - bar_number: 0, - tsig_num: config.time_sig_numerator, - tsig_denom: config.time_sig_denominator, - }, - sample_pos: 0, - } - } - - /// Construct the CLAP process data, and evaluate a closure with it. The `clap_process_data` - /// contains raw pointers to this struct's data, so the closure is there to prevent dangling - /// pointers. - pub fn with_clap_process_data T>(&mut self, f: F) -> T { - let num_samples = self.buffers.len(); - let (inputs, outputs) = self.buffers.io_buffers(); - - let process_data = clap_process { - steady_time: self.sample_pos as i64, - frames_count: num_samples as u32, - transport: &self.transport_info, - audio_inputs: if inputs.is_empty() { - std::ptr::null() - } else { - inputs.as_ptr() - }, - audio_outputs: if outputs.is_empty() { - std::ptr::null_mut() - } else { - outputs.as_mut_ptr() - }, - audio_inputs_count: inputs.len() as u32, - audio_outputs_count: outputs.len() as u32, - in_events: &self.input_events.vtable, - out_events: &self.output_events.vtable, - }; - - f(process_data) - } - - /// Get current the transport information. - #[allow(unused)] - pub fn transport_info(&self) -> clap_event_transport { - self.transport_info - } - - /// Advance the transport by a certain number of samples. Make sure to also call - /// [`clear_events()`][Self::clear_events()]. - pub fn advance_transport(&mut self, samples: u32) { - self.sample_pos += samples; - - self.transport_info.song_pos_beats = - ((self.sample_pos as f64 / self.config.sample_rate / 60.0 * self.transport_info.tempo) - * CLAP_BEATTIME_FACTOR as f64) - .round() as i64; - self.transport_info.song_pos_seconds = ((self.sample_pos as f64 / self.config.sample_rate) - * CLAP_SECTIME_FACTOR as f64) - .round() as i64; - } - - /// Clear the event queues. Make sure to also call - /// [`advance_transport()`][Self::advance_transport()]. - pub fn clear_events(&mut self) { - self.input_events.events.lock().clear(); - self.output_events.events.lock().clear(); - } -} - -impl AudioBuffers<'_> { - /// The number of samples in the buffer. - pub fn len(&self) -> usize { - match self { - AudioBuffers::OutOfPlace(buffers) => buffers.len(), - } - } - - /// Pointers for the inputs and the outputs. These can be used to construct the `clap_process` - /// data. - pub fn io_buffers(&mut self) -> (&[clap_audio_buffer], &mut [clap_audio_buffer]) { - match self { - AudioBuffers::OutOfPlace(buffers) => buffers.io_buffers(), - } - } - - /// Get a reference to the buffer's inputs. - pub fn inputs_ref(&self) -> &[Vec>] { - match self { - AudioBuffers::OutOfPlace(buffers) => buffers.inputs, - } - } - - /// Get a reference to the buffer's outputs. - pub fn outputs_ref(&self) -> &[Vec>] { - match self { - AudioBuffers::OutOfPlace(buffers) => buffers.outputs, - } - } - - /// Fill the input and output buffers with white noise. The values are distributed between `[-1, - /// 1]`, and denormals are snapped to zero. - pub fn randomize(&mut self, prng: &mut Pcg32) { - match self { - AudioBuffers::OutOfPlace(buffers) => buffers.randomize(prng), - } - } -} - -impl<'a> OutOfPlaceAudioBuffers<'a> { - /// Construct the out of place audio buffers. This allocates the channel pointers that are - /// handed to the plugin in the process function. The function will return an error if the - /// sample count doesn't match between all input and outputs vectors. - pub fn new(inputs: &'a mut [Vec>], outputs: &'a mut [Vec>]) -> Result { - // We need to make sure all inputs and outputs have the same number of channels. Since zero - // channel ports are technically legal and it's also possible to not have any inputs we - // can't just start with the first input. - let mut num_samples = None; - for channel_slices in inputs.iter().chain(outputs.iter()) { - for channel_slice in channel_slices { - match num_samples { - Some(num_samples) if channel_slice.len() != num_samples => anyhow::bail!( - "Inconsistent sample counts in audio buffers. Expected {}, found {}.", - num_samples, - channel_slice.len() - ), - Some(_) => (), - None => num_samples = Some(channel_slice.len()), - } - } - } - - let input_channel_pointers: Vec> = inputs - .iter() - .map(|channel_slices| { - channel_slices - .iter() - .map(|channel_slice| channel_slice.as_ptr()) - .collect() - }) - .collect(); - // These are always `*const` pointers in CLAP, even for output buffers - let output_channel_pointers: Vec> = outputs - .iter() - .map(|channel_slices| { - channel_slices - .iter() - .map(|channel_slice| channel_slice.as_ptr()) - .collect() - }) - .collect(); - - let clap_inputs: Vec = input_channel_pointers - .iter() - .map(|channel_pointers| clap_audio_buffer { - data32: channel_pointers.as_ptr(), - data64: std::ptr::null(), - channel_count: channel_pointers.len() as u32, - // TODO: Do some interesting tests with these two fields - latency: 0, - constant_mask: 0, - }) - .collect(); - let clap_outputs: Vec = output_channel_pointers - .iter() - .map(|channel_pointers| clap_audio_buffer { - data32: channel_pointers.as_ptr(), - data64: std::ptr::null(), - channel_count: channel_pointers.len() as u32, - latency: 0, - constant_mask: 0, - }) - .collect(); - - Ok(Self { - inputs, - outputs, - _input_channel_pointers: input_channel_pointers, - _output_channel_pointers: output_channel_pointers, - clap_inputs, - clap_outputs, - - // This cannot default to 0, because 0 isn't a valid buffer size in CLAP - num_samples: num_samples.unwrap_or(512), - }) - } - - /// The number of samples in the buffer. - pub fn len(&self) -> usize { - self.num_samples - } - - /// Pointers for the inputs and the outputs. These can be used to construct the `clap_process` - /// data. - pub fn io_buffers(&mut self) -> (&[clap_audio_buffer], &mut [clap_audio_buffer]) { - (&self.clap_inputs, &mut self.clap_outputs) - } - - /// Fill the input and output buffers with white noise. The values are distributed between `[-1, - /// 1]`, and denormals are snapped to zero. - pub fn randomize(&mut self, prng: &mut Pcg32) { - randomize_audio_buffers(prng, self.inputs); - randomize_audio_buffers(prng, self.outputs); - } -} - -impl EventQueue { - /// Construct a new event queue. This can be used as both an input and an output queue. - pub fn new_input() -> Pin> { - let mut queue = Box::pin(EventQueue { - vtable: clap_input_events { - // This is set to point to this object below - ctx: std::ptr::null_mut(), - size: Some(Self::size), - get: Some(Self::get), - }, - // Using a mutex here is obviously a terrible idea in a real host, but we're not a real - // host - events: Mutex::new(Vec::new()), - }); - - queue.vtable.ctx = &*queue as *const Self as *mut c_void; - - queue - } -} - -impl EventQueue { - /// Construct a new output event queue. - pub fn new_output() -> Pin> { - let mut queue = Box::pin(EventQueue { - vtable: clap_output_events { - // This is set to point to this object below - ctx: std::ptr::null_mut(), - try_push: Some(Self::try_push), - }, - // Using a mutex here is obviously a terrible idea in a real host, but we're not a real - // host - events: Mutex::new(Vec::new()), - }); - - queue.vtable.ctx = &*queue as *const Self as *mut c_void; - - queue - } -} - -impl EventQueue { - pub fn vtable(self: &Pin>) -> *const VTable { - &self.vtable - } - - unsafe extern "C" fn size(list: *const clap_input_events) -> u32 { - check_null_ptr!(list, (*list).ctx); - let this = &*((*list).ctx as *const Self); - - this.events.lock().len() as u32 - } - - unsafe extern "C" fn get( - list: *const clap_input_events, - index: u32, - ) -> *const clap_event_header { - check_null_ptr!(list, (*list).ctx); - let this = &*((*list).ctx as *const Self); - - let events = this.events.lock(); - match events.get(index as usize) { - Some(event) => event.header(), - None => { - log::warn!( - "The plugin tried to get an event with index {index} ({} total events)", - events.len() - ); - std::ptr::null() - } - } - } - - unsafe extern "C" fn try_push( - list: *const clap_output_events, - event: *const clap_event_header, - ) -> bool { - check_null_ptr!(list, (*list).ctx, event); - let this = &*((*list).ctx as *const Self); - - // The monotonicity of the plugin's event insertion order is checked as part of the output - // consistency checks - this.events - .lock() - .push(Event::from_header_ptr(event).unwrap()); - - true - } -} - -impl Event { - /// Parse an event from a plugin-provided pointer. Returns an error if the pointer as a null pointer - pub unsafe fn from_header_ptr(ptr: *const clap_event_header) -> Result { - if ptr.is_null() { - anyhow::bail!("Null pointer provided for 'clap_event_header'."); - } - - match ((*ptr).space_id, ((*ptr).type_)) { - ( - CLAP_CORE_EVENT_SPACE_ID, - CLAP_EVENT_NOTE_ON - | CLAP_EVENT_NOTE_OFF - | CLAP_EVENT_NOTE_CHOKE - | CLAP_EVENT_NOTE_END, - ) => Ok(Event::Note(*(ptr as *const clap_event_note))), - (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_EXPRESSION) => Ok(Event::NoteExpression( - *(ptr as *const clap_event_note_expression), - )), - (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_VALUE) => { - Ok(Event::ParamValue(*(ptr as *const clap_event_param_value))) - } - (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_MOD) => { - Ok(Event::ParamMod(*(ptr as *const clap_event_param_mod))) - } - (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI) => { - Ok(Event::Midi(*(ptr as *const clap_event_midi))) - } - (_, _) => Ok(Event::Unknown(*ptr)), - } - } - - /// Get a a reference to the event's header. - pub fn header(&self) -> &clap_event_header { - match self { - Event::Note(event) => &event.header, - Event::NoteExpression(event) => &event.header, - Event::ParamValue(event) => &event.header, - Event::ParamMod(event) => &event.header, - Event::Midi(event) => &event.header, - Event::Unknown(header) => header, - } - } -} - -/// Set each sample in the buffers to a random value in `[-1, 1]`. Denormals are snapped to zero. -fn randomize_audio_buffers(prng: &mut Pcg32, buffers: &mut [Vec>]) { - for channel_slices in buffers { - for channel_slice in channel_slices { - for sample in channel_slice { - *sample = prng.gen_range(-1.0..=1.0); - if sample.is_subnormal() { - *sample = 0.0; - } - } - } - } -} diff --git a/src/plugin/instance/shared.rs b/src/plugin/instance/shared.rs new file mode 100644 index 0000000..31f14ff --- /dev/null +++ b/src/plugin/instance/shared.rs @@ -0,0 +1,860 @@ +use crate::cli::fail_test; +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::Extension; +use crate::plugin::ext::audio_ports::AudioPorts; +use crate::plugin::ext::audio_ports_config::AudioPortsConfig; +use crate::plugin::ext::latency::Latency; +use crate::plugin::ext::note_ports::NotePorts; +use crate::plugin::ext::params::{Params, ParamsRescan}; +use crate::plugin::ext::preset_load::PresetLoad; +use crate::plugin::ext::state::State; +use crate::plugin::ext::tail::Tail; +use crate::plugin::ext::thread_pool::ThreadPool; +use crate::plugin::ext::voice_info::VoiceInfo; +use crate::plugin::instance::{CallbackEvent, Plugin, PluginStatus}; +use crate::plugin::preset_discovery::LocationValue; +use crate::plugin::util::{self, CHECK_POINTER, Proxy, Proxyable, clap_call, cstr_ptr_to_string, validator_version}; +use anyhow::{Context, Result}; +use clap_sys::ext::audio_ports::*; +use clap_sys::ext::audio_ports_config::{CLAP_EXT_AUDIO_PORTS_CONFIG, clap_host_audio_ports_config}; +use clap_sys::ext::latency::*; +use clap_sys::ext::log::*; +use clap_sys::ext::note_ports::*; +use clap_sys::ext::params::*; +use clap_sys::ext::preset_load::{CLAP_EXT_PRESET_LOAD, clap_host_preset_load}; +use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state}; +use clap_sys::ext::tail::{CLAP_EXT_TAIL, clap_host_tail}; +use clap_sys::ext::thread_check::{CLAP_EXT_THREAD_CHECK, clap_host_thread_check}; +use clap_sys::ext::thread_pool::{CLAP_EXT_THREAD_POOL, clap_host_thread_pool}; +use clap_sys::ext::voice_info::{CLAP_EXT_VOICE_INFO, clap_host_voice_info}; +use clap_sys::factory::plugin_factory::clap_plugin_factory; +use clap_sys::factory::preset_discovery::clap_preset_discovery_location_kind; +use clap_sys::host::clap_host; +use clap_sys::id::clap_id; +use clap_sys::plugin::clap_plugin; +use clap_sys::version::CLAP_VERSION; +use crossbeam_utils::atomic::AtomicCell; +use std::ffi::{CStr, c_char, c_void}; +use std::ptr::NonNull; +use std::sync::Mutex; +use std::sync::mpsc::{Sender, channel}; +use std::thread::ThreadId; + +#[derive(Debug, Clone, Copy)] +pub struct HostCapabilities { + pub has_tail_extension: bool, + pub has_latency_extension: bool, + pub has_state_extension: bool, + pub has_params_extension: bool, + pub has_audio_ports_extension: bool, + pub has_note_ports_extension: bool, + pub has_thread_pool_extension: bool, + + pub supports_clap_dialect: bool, + pub supports_midi_dialect: bool, + pub can_rescan_audio_ports: bool, +} + +impl Default for HostCapabilities { + fn default() -> Self { + Self { + has_tail_extension: true, + has_latency_extension: true, + has_state_extension: true, + has_params_extension: true, + has_audio_ports_extension: true, + has_note_ports_extension: true, + has_thread_pool_extension: true, + + supports_clap_dialect: true, + supports_midi_dialect: true, + can_rescan_audio_ports: false, + } + } +} + +/// Plugin instance state that is shared between the main thread, audio thread and any external unmanaged threads. +/// This struct also acts as the `clap_host` implementation for the plugin instance. +pub struct PluginShared { + pub capabilities: HostCapabilities, + + pub callback_sender: Sender, + pub callback_error: Mutex>, + + /// The plugin's current state in terms of activation and processing status. + status: AtomicCell, + + /// The plugin instance's main thread. Used for the main thread checks. + pub main_thread_id: ThreadId, + + /// The plugin instance's audio thread, if it has one. Used for the audio thread checks. + pub audio_thread_id: AtomicCell>, + + /// Whether the plugin has called `clap_host::request_callback()` and expects + /// `clap_plugin::on_main_thread()` to be called on the main thread. + pub requested_callback: AtomicCell, + + /// Whether the plugin has called `clap_host::request_restart()` and expects the plugin to be + /// deactivated and subsequently reactivated. + pub requested_restart: AtomicCell, + + /// Whether the plugin is currently being called from within a process call. This is used to + /// check that certain functions (like thread_pool::request_exec()) are called from the process function. + pub is_currently_in_process_call: AtomicCell, + + pub clap_plugin: *const clap_plugin, +} + +unsafe impl Send for PluginShared {} +unsafe impl Sync for PluginShared {} + +impl Proxyable for PluginShared { + type Vtable = clap_host; + + fn init(&self) -> Self::Vtable { + clap_host { + clap_version: CLAP_VERSION, + host_data: CHECK_POINTER, + name: c"clap-validator".as_ptr(), + vendor: c"Robbert van der Helm".as_ptr(), + url: c"https://github.com/free-audio/clap-validator".as_ptr(), + version: validator_version().as_ptr(), + get_extension: Some(Self::clap_get_extension), + request_restart: Some(Self::clap_request_restart), + request_process: Some(Self::clap_request_process), + request_callback: Some(Self::clap_request_callback), + } + } +} + +impl PluginShared { + /// Create a plugin instance and return the still uninitialized plugin. Returns an error if the + /// plugin could not be created. The plugin instance will be registered with the host, and + /// unregistered when this object is dropped again. + /// + /// # Safety + /// The `factory` object must be valid. + /// The caller must ensure that this is called from the OS main thread. + pub unsafe fn create_plugin<'a>( + factory: *const clap_plugin_factory, + plugin_id: &CStr, + capabilities: HostCapabilities, + ) -> Result> { + let (callback_sender, callback_receiver) = channel(); + + let shared = Proxy::new(PluginShared { + capabilities, + + callback_sender, + callback_error: Mutex::new(None), + + status: AtomicCell::new(PluginStatus::Uninitialized), + main_thread_id: std::thread::current().id(), + audio_thread_id: AtomicCell::new(None), + requested_callback: AtomicCell::new(false), + requested_restart: AtomicCell::new(false), + is_currently_in_process_call: AtomicCell::new(false), + + clap_plugin: std::ptr::null(), + }); + + let span = Span::begin( + "clap_plugin_factory::create_plugin", + record!( + plugin_id: plugin_id.to_string_lossy() + ), + ); + + let clap_plugin = unsafe { + clap_call! { + factory=>create_plugin(factory, Proxy::vtable(&shared), plugin_id.as_ptr()) + } + }; + + span.finish(record!(result: format_args!("{:p}", clap_plugin))); + + if clap_plugin.is_null() { + anyhow::bail!("'clap_plugin_factory::create_plugin({plugin_id:?})' returned a null pointer."); + } + + unsafe { + (&raw const shared.clap_plugin).cast_mut().write(clap_plugin); + } + + Ok(Plugin { + shared, + callback_receiver, + + _library: std::marker::PhantomData, + _thread: std::marker::PhantomData, + }) + } + + /// Get the raw extension pointer for the extension `T`, if the plugin supports this extension. + pub fn raw_extension(&self) -> Option> { + self.status().assert_is_not(PluginStatus::Uninitialized); + + for id in T::IDS { + let span = Span::begin( + "clap_plugin::get_extension", + record! { + extension_id: id.to_string_lossy() + }, + ); + + let extension_ptr = unsafe { + clap_call! { self.clap_plugin=>get_extension(self.clap_plugin, id.as_ptr()) } + }; + + span.finish(record!(result: format_args!("{:p}", extension_ptr))); + + if !extension_ptr.is_null() { + return NonNull::new(extension_ptr as *mut T::Struct); + } + } + + None + } + + /// Get a shared extension abstraction for the extension `T`, if the plugin supports this extension. + pub fn get_extension<'a, T: Extension>(&'a self) -> Option { + unsafe { self.raw_extension::().map(|ptr| T::new(self, ptr)) } + } + + /// The plugin's current initialization status. + pub fn status(&self) -> PluginStatus { + self.status.load() + } + + pub fn set_status(&self, status: PluginStatus) { + self.status.store(status); + } + + #[track_caller] + fn wrap(host: *const clap_host, function_name: &'static str, f: impl FnOnce(&Self) -> Result) -> Option { + let state = unsafe { + Proxy::::from_vtable(host).unwrap_or_else(|e| { + fail_test!("{}: {}", function_name, e); + }) + }; + + if Proxy::vtable(&state).host_data != CHECK_POINTER { + fail_test!("{}: plugin messed with the 'host_data' pointer", function_name); + } + + match f(&state) { + Ok(result) => Some(result), + Err(error) => { + log::error!("{:#}", error); + + let mut guard = state.callback_error.lock().unwrap(); + if guard.is_none() { + *guard = Some(error.context(function_name.to_string())); + } + + None + } + } + } + + /// Checks whether this is the main thread. If it is not, then an error indicating this can be + /// retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread + /// safety errors will not overwrite earlier ones. + fn assert_main_thread(&self) -> Result<()> { + let current_thread_id = std::thread::current().id(); + + anyhow::ensure!( + current_thread_id == self.main_thread_id, + "The function may only be called from the main thread (thread {:?}), but it was called from thread {:?}.", + self.main_thread_id, + current_thread_id + ); + + Ok(()) + } + + /// Checks whether this is the audio thread. If it is not, then an error indicating this can be + /// retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread + /// safety errors will not overwrite earlier ones. + fn assert_audio_thread(&self) -> Result<()> { + let current_thread_id = std::thread::current().id(); + if self.audio_thread_id.load() != Some(current_thread_id) { + if current_thread_id == self.main_thread_id { + anyhow::bail!( + "This function may only be called from an audio thread, but it was called from the main thread." + ); + } else { + anyhow::bail!( + "This function may only be called from an audio thread, but it was called from an unknown thread \ + ({:?}).", + current_thread_id + ); + } + } + + Ok(()) + } + + /// Checks whether this is **not** the audio thread. If it is, then an error indicating this can + /// be retrieved using [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread + /// safety errors will not overwrite earlier ones. + fn assert_not_audio_thread(&self) -> Result<()> { + let current_thread_id = std::thread::current().id(); + if self.audio_thread_id.load() == Some(current_thread_id) { + anyhow::bail!("This function was called from the audio thread, this is not allowed."); + } + Ok(()) + } + + /// Checks whether the plugin has the required extension(s). If it does not, then an error + /// will be set. Subsequent errors will not overwrite earlier ones. + fn assert_has_extension(&self) -> Result<()> { + anyhow::ensure!( + self.status() != PluginStatus::Uninitialized, + "Called while the plugin is uninitialized" + ); + + anyhow::ensure!( + self.raw_extension::().is_some(), + "Plugin does not implement extension '{}'", + T::IDS[0].to_string_lossy() + ); + + Ok(()) + } +} + +// Extensions +impl PluginShared { + const EXT_AUDIO_PORTS: clap_host_audio_ports = clap_host_audio_ports { + is_rescan_flag_supported: Some(Self::ext_audio_ports_is_rescan_flag_supported), + rescan: Some(Self::ext_audio_ports_rescan), + }; + + const EXT_NOTE_PORTS: clap_host_note_ports = clap_host_note_ports { + supported_dialects: Some(Self::ext_note_ports_supported_dialects), + rescan: Some(Self::ext_note_ports_rescan), + }; + + const EXT_PRESET_LOAD: clap_host_preset_load = clap_host_preset_load { + on_error: Some(Self::ext_preset_load_on_error), + loaded: Some(Self::ext_preset_load_loaded), + }; + + const EXT_PARAMS: clap_host_params = clap_host_params { + rescan: Some(Self::ext_params_rescan), + clear: Some(Self::ext_params_clear), + request_flush: Some(Self::ext_params_request_flush), + }; + + const EXT_STATE: clap_host_state = clap_host_state { + mark_dirty: Some(Self::ext_state_mark_dirty), + }; + + const EXT_THREAD_CHECK: clap_host_thread_check = clap_host_thread_check { + is_audio_thread: Some(Self::ext_thread_check_is_audio_thread), + is_main_thread: Some(Self::ext_thread_check_is_main_thread), + }; + + const EXT_LOG: clap_host_log = clap_host_log { + log: Some(Self::ext_log_log), + }; + + const EXT_THREAD_POOL: clap_host_thread_pool = clap_host_thread_pool { + request_exec: Some(Self::ext_thread_pool_request_exec), + }; + + const EXT_LATENCY: clap_host_latency = clap_host_latency { + changed: Some(Self::ext_latency_changed), + }; + + const EXT_TAIL: clap_host_tail = clap_host_tail { + changed: Some(Self::ext_tail_changed), + }; + + const EXT_VOICE_INFO: clap_host_voice_info = clap_host_voice_info { + changed: Some(Self::ext_voice_info_changed), + }; + + const EXT_AUDIO_PORTS_CONFIG: clap_host_audio_ports_config = clap_host_audio_ports_config { + rescan: Some(Self::ext_audio_ports_config_rescan), + }; + + unsafe extern "C" fn clap_get_extension(host: *const clap_host, extension_id: *const c_char) -> *const c_void { + let extension_id_cstr = if extension_id.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(extension_id) }) + }; + + let span = Span::begin( + "clap_host::get_extension", + record! { + extension_id: match extension_id_cstr { + Some(id) => id.to_string_lossy(), + None => "".into() + } + }, + ); + + // Right now there's no way to have the host only expose certain extensions. We can always + // add that when test cases need it. + Self::wrap(host, span.name(), |host| { + let Some(extension_id_cstr) = extension_id_cstr else { + anyhow::bail!("Null extension ID"); + }; + + let extension_ptr = + if extension_id_cstr == CLAP_EXT_AUDIO_PORTS && host.capabilities.has_audio_ports_extension { + &Self::EXT_AUDIO_PORTS as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_NOTE_PORTS && host.capabilities.has_note_ports_extension { + &Self::EXT_NOTE_PORTS as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_PRESET_LOAD { + &Self::EXT_PRESET_LOAD as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_PARAMS && host.capabilities.has_params_extension { + &Self::EXT_PARAMS as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_STATE && host.capabilities.has_state_extension { + &Self::EXT_STATE as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_THREAD_CHECK { + &Self::EXT_THREAD_CHECK as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_THREAD_POOL && host.capabilities.has_thread_pool_extension { + &Self::EXT_THREAD_POOL as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_LOG { + &Self::EXT_LOG as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_LATENCY && host.capabilities.has_latency_extension { + &Self::EXT_LATENCY as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_TAIL && host.capabilities.has_tail_extension { + &Self::EXT_TAIL as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_VOICE_INFO { + &Self::EXT_VOICE_INFO as *const _ as *const c_void + } else if extension_id_cstr == CLAP_EXT_AUDIO_PORTS_CONFIG { + &Self::EXT_AUDIO_PORTS_CONFIG as *const _ as *const c_void + } else { + std::ptr::null() + }; + + span.finish(record!(result: format_args!("{:p}", extension_ptr))); + + Ok(extension_ptr) + }) + .unwrap_or_default() + } + + unsafe extern "C" fn clap_request_restart(host: *const clap_host) { + let span = Span::begin("clap_host::request_restart", ()); + + Self::wrap(host, span.name(), |this| { + this.requested_restart.store(true); + Ok(()) + }); + } + + unsafe extern "C" fn clap_request_process(host: *const clap_host) { + let span = Span::begin("clap_host::request_process", ()); + + Self::wrap(host, span.name(), |this| { + this.callback_sender.send(CallbackEvent::RequestProcess).unwrap(); + Ok(()) + }); + } + + unsafe extern "C" fn clap_request_callback(host: *const clap_host) { + let span = Span::begin("clap_host::request_callback", ()); + + Self::wrap(host, span.name(), |this| { + this.requested_callback.store(true); + Ok(()) + }); + } + + unsafe extern "C" fn ext_audio_ports_is_rescan_flag_supported(host: *const clap_host, flag: u32) -> bool { + let span = Span::begin( + "clap_host_audio_ports::is_rescan_flag_supported", + record! { + flag: match flag { + CLAP_AUDIO_PORTS_RESCAN_NAMES => "CLAP_AUDIO_PORTS_RESCAN_NAMES", + CLAP_AUDIO_PORTS_RESCAN_FLAGS => "CLAP_AUDIO_PORTS_RESCAN_FLAGS", + CLAP_AUDIO_PORTS_RESCAN_CHANNEL_COUNT => "CLAP_AUDIO_PORTS_RESCAN_CHANNEL_COUNT", + CLAP_AUDIO_PORTS_RESCAN_PORT_TYPE => "CLAP_AUDIO_PORTS_RESCAN_PORT_TYPE", + CLAP_AUDIO_PORTS_RESCAN_IN_PLACE_PAIR => "CLAP_AUDIO_PORTS_RESCAN_IN_PLACE_PAIR", + CLAP_AUDIO_PORTS_RESCAN_LIST => "CLAP_AUDIO_PORTS_RESCAN_LIST", + _ => "?" + } + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + Ok(this.capabilities.can_rescan_audio_ports) + }) + .unwrap_or(false) + } + + unsafe extern "C" fn ext_audio_ports_rescan(host: *const clap_host, flags: u32) { + let span = Span::begin( + "clap_host_audio_ports::rescan", + record! { + rescan_names: flags & CLAP_AUDIO_PORTS_RESCAN_NAMES != 0, + rescan_flags: flags & CLAP_AUDIO_PORTS_RESCAN_FLAGS != 0, + rescan_channel_count: flags & CLAP_AUDIO_PORTS_RESCAN_CHANNEL_COUNT != 0, + rescan_port_type: flags & CLAP_AUDIO_PORTS_RESCAN_PORT_TYPE != 0, + rescan_in_place_pair: flags & CLAP_AUDIO_PORTS_RESCAN_IN_PLACE_PAIR != 0, + rescan_list: flags & CLAP_AUDIO_PORTS_RESCAN_LIST != 0 + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + anyhow::ensure!( + this.capabilities.can_rescan_audio_ports, + "Called when the host reported that it doesn't support rescanning audio ports." + ); + + if flags & CLAP_AUDIO_PORTS_RESCAN_NAMES != 0 { + this.callback_sender.send(CallbackEvent::AudioPortsRescanNames).unwrap(); + } + + if (flags & CLAP_AUDIO_PORTS_RESCAN_FLAGS != 0) + || (flags & CLAP_AUDIO_PORTS_RESCAN_CHANNEL_COUNT != 0) + || (flags & CLAP_AUDIO_PORTS_RESCAN_PORT_TYPE != 0) + || (flags & CLAP_AUDIO_PORTS_RESCAN_IN_PLACE_PAIR != 0) + { + anyhow::ensure!( + this.status() <= PluginStatus::Activated, + "Called while the plugin is active" + ); + + this.callback_sender.send(CallbackEvent::AudioPortsRescanInfo).unwrap(); + } + + if flags & CLAP_AUDIO_PORTS_RESCAN_LIST != 0 { + anyhow::ensure!( + this.status() <= PluginStatus::Activated, + "Called while the plugin is active" + ); + + this.callback_sender.send(CallbackEvent::AudioPortsRescanList).unwrap(); + } + + Ok(()) + }); + } + + unsafe extern "C" fn ext_note_ports_supported_dialects(host: *const clap_host) -> clap_note_dialect { + let span = Span::begin("clap_host_note_ports::supported_dialects", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + let mut flags = 0; + + if this.capabilities.supports_clap_dialect { + flags |= CLAP_NOTE_DIALECT_CLAP; + } + + if this.capabilities.supports_midi_dialect { + flags |= CLAP_NOTE_DIALECT_MIDI | CLAP_NOTE_DIALECT_MIDI_MPE; + } + + Ok(flags) + }) + .unwrap_or(0) + } + + unsafe extern "C" fn ext_note_ports_rescan(host: *const clap_host, flags: u32) { + let span = Span::begin( + "clap_host_note_ports::rescan", + record! { + rescan_names: flags & CLAP_NOTE_PORTS_RESCAN_NAMES != 0, + rescan_all: flags & CLAP_NOTE_PORTS_RESCAN_ALL != 0 + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + if flags & CLAP_NOTE_PORTS_RESCAN_NAMES != 0 { + this.callback_sender.send(CallbackEvent::NotePortsRescanNames).unwrap(); + } + + if flags & CLAP_NOTE_PORTS_RESCAN_ALL != 0 { + anyhow::ensure!( + this.status() <= PluginStatus::Activated, + "Called while the plugin is active" + ); + + this.callback_sender.send(CallbackEvent::NotePortsRescanAll).unwrap(); + } + + Ok(()) + }); + } + + unsafe extern "C" fn ext_preset_load_on_error( + host: *const clap_host, + location_kind: clap_preset_discovery_location_kind, + location: *const c_char, + load_key: *const c_char, + os_error: i32, + msg: *const c_char, + ) { + Self::wrap(host, "clap_host_preset_load::on_error", |this| -> Result<()> { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + let location = unsafe { LocationValue::new(location_kind, location) } + .context("'clap_host_preset_load::on_error()' called with invalid location parameters")?; + let load_key = unsafe { util::cstr_ptr_to_optional_string(load_key) } + .context("'clap_host_preset_load::on_error()' called with an invalid load_key parameter")?; + let msg = unsafe { util::cstr_ptr_to_mandatory_string(msg) } + .context("'clap_host_preset_load::on_error()' called with an invalid msg parameter")?; + + if let Some(load_key) = &load_key { + anyhow::bail!( + "Called for {location} with load key {load_key}, OS error code {os_error}, and the following \ + error message: {msg}" + ); + } else { + anyhow::bail!( + "Called for {location} with no load key, OS error code {os_error}, and the following error \ + message: {msg}" + ); + } + }); + } + + unsafe extern "C" fn ext_preset_load_loaded( + host: *const clap_host, + location_kind: clap_preset_discovery_location_kind, + location: *const c_char, + load_key: *const c_char, + ) { + Self::wrap(host, "clap_host_preset_load::loaded", |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + let _location = unsafe { LocationValue::new(location_kind, location) } + .context("'Called with invalid location parameters")?; + let _load_key = unsafe { util::cstr_ptr_to_optional_string(load_key) } + .context("'Called with an invalid load_key parameter")?; + + log::debug!("TODO: Handle 'clap_host_preset_load::loaded()'"); + Ok(()) + }); + } + + unsafe extern "C" fn ext_params_rescan(host: *const clap_host, flags: clap_param_rescan_flags) { + let span = Span::begin( + "clap_host_params::rescan", + record! { + rescan_values: flags & CLAP_PARAM_RESCAN_VALUES != 0, + rescan_text: flags & CLAP_PARAM_RESCAN_TEXT != 0, + rescan_info: flags & CLAP_PARAM_RESCAN_INFO != 0, + rescan_all: flags & CLAP_PARAM_RESCAN_ALL != 0 + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + if flags & CLAP_PARAM_RESCAN_VALUES != 0 { + this.callback_sender + .send(CallbackEvent::ParamsRescan(ParamsRescan::Values)) + .unwrap(); + } + + if flags & CLAP_PARAM_RESCAN_TEXT != 0 { + this.callback_sender + .send(CallbackEvent::ParamsRescan(ParamsRescan::Text)) + .unwrap(); + } + + if flags & CLAP_PARAM_RESCAN_INFO != 0 { + this.callback_sender + .send(CallbackEvent::ParamsRescan(ParamsRescan::Info)) + .unwrap(); + } + + if flags & CLAP_PARAM_RESCAN_ALL != 0 { + anyhow::ensure!( + this.status() <= PluginStatus::Activated, + "Called while the plugin is active" + ); + + this.callback_sender + .send(CallbackEvent::ParamsRescan(ParamsRescan::All)) + .unwrap(); + } + + Ok(()) + }); + } + + unsafe extern "C" fn ext_params_clear(host: *const clap_host, param_id: clap_id, flags: clap_param_clear_flags) { + let span = Span::begin( + "clap_host_params::clear", + record! { + param_id: param_id, + clear_all: flags & CLAP_PARAM_CLEAR_ALL != 0, + clear_modulations: flags & CLAP_PARAM_CLEAR_MODULATIONS != 0, + clear_automations: flags & CLAP_PARAM_CLEAR_AUTOMATIONS != 0 + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + log::debug!("TODO: Handle 'clap_host_params::clear()'"); + Ok(()) + }); + } + + unsafe extern "C" fn ext_params_request_flush(host: *const clap_host) { + let span = Span::begin("clap_host_params::request_flush", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_not_audio_thread()?; + this.assert_has_extension::()?; + this.callback_sender.send(CallbackEvent::RequestFlush).unwrap(); + Ok(()) + }); + } + + unsafe extern "C" fn ext_state_mark_dirty(host: *const clap_host) { + let span = Span::begin("clap_host_state::mark_dirty", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + this.callback_sender.send(CallbackEvent::StateMarkDirty).unwrap(); + Ok(()) + }); + } + + // these 3 functions are explicitly uninstrumented to avoid overhead and unnecesary noise + unsafe extern "C" fn ext_thread_check_is_main_thread(host: *const clap_host) -> bool { + Self::wrap(host, "clap_host_thread_check::is_main_thread", |this| { + Ok(this.main_thread_id == std::thread::current().id()) + }) + .unwrap_or(false) + } + + unsafe extern "C" fn ext_thread_check_is_audio_thread(host: *const clap_host) -> bool { + Self::wrap(host, "clap_host_thread_check::is_audio_thread", |this| { + Ok(this.audio_thread_id.load() == Some(std::thread::current().id())) + }) + .unwrap_or(false) + } + + unsafe extern "C" fn ext_log_log(_host: *const clap_host, level: i32, msg: *const c_char) { + let msg = match unsafe { cstr_ptr_to_string(msg) } { + Ok(Some(msg)) => msg, + Ok(None) => "".into(), + Err(_) => "".into(), + }; + + match level { + CLAP_LOG_ERROR => log::error!(target: "plugin::error", "{}", msg), + CLAP_LOG_FATAL => log::error!(target: "plugin::fatal", "{}", msg), + CLAP_LOG_WARNING => log::warn!(target: "plugin::warning", "{}", msg), + CLAP_LOG_INFO => log::info!(target: "plugin::info", "{}", msg), + CLAP_LOG_DEBUG => log::debug!(target: "plugin::debug", "{}", msg), + CLAP_LOG_HOST_MISBEHAVING => log::error!(target: "plugin::host-misbehaving", "{}", msg), + CLAP_LOG_PLUGIN_MISBEHAVING => log::error!(target: "plugin::plugin-misbehaving", "{}", msg), + _ => log::debug!(target: "plugin", "{}", msg), + } + } + + unsafe extern "C" fn ext_latency_changed(host: *const clap_host) { + let span = Span::begin("clap_host_latency::changed", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + + anyhow::ensure!( + this.status() == PluginStatus::Activating, + "Must only be called within 'clap_plugin::activate'" + ); + + this.callback_sender.send(CallbackEvent::LatencyChanged).unwrap(); + + Ok(()) + }); + } + + unsafe extern "C" fn ext_tail_changed(host: *const clap_host) { + let span = Span::begin("clap_host_tail::changed", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_audio_thread()?; + this.assert_has_extension::()?; + this.callback_sender.send(CallbackEvent::TailChanged).unwrap(); + Ok(()) + }); + } + + unsafe extern "C" fn ext_voice_info_changed(host: *const clap_host) { + let span = Span::begin("clap_host_voice_info::changed", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + this.callback_sender.send(CallbackEvent::VoiceInfoChanged).unwrap(); + Ok(()) + }); + } + + unsafe extern "C" fn ext_audio_ports_config_rescan(host: *const clap_host) { + let span = Span::begin("clap_host_audio_ports_config::rescan", ()); + + Self::wrap(host, span.name(), |this| { + this.assert_main_thread()?; + this.assert_has_extension::()?; + this.callback_sender + .send(CallbackEvent::AudioPortsConfigRescan) + .unwrap(); + Ok(()) + }); + } + + unsafe extern "C" fn ext_thread_pool_request_exec(host: *const clap_host, num_tasks: u32) -> bool { + let span = Span::begin( + "clap_host_thread_pool::request_exec", + record! { + num_tasks: num_tasks + }, + ); + + Self::wrap(host, span.name(), |this| { + this.assert_audio_thread()?; + this.assert_has_extension::()?; + + // Ensure this is called from within the process() function + // We already checked that we're on the audio thread, so this is sufficient + anyhow::ensure!( + this.is_currently_in_process_call.load(), + "Must only be called from within the 'clap_plugin::process' function." + ); + + let extension = this.get_extension::().unwrap(); + + std::thread::scope(|s| { + for i in 0..num_tasks { + s.spawn(move || { + extension.exec(i); + }); + } + }); + + Ok(true) + }) + .unwrap_or(false) + } +} diff --git a/src/plugin/library.rs b/src/plugin/library.rs index 7fb113c..922b3f8 100644 --- a/src/plugin/library.rs +++ b/src/plugin/library.rs @@ -1,24 +1,24 @@ //! Interactions with CLAP plugin libraries, which may contain multiple plugins. +use super::instance::Plugin; +use super::preset_discovery::PresetDiscoveryFactory; +use super::util::{self, clap_call}; +use crate::cli::tracing::{Span, record}; +use crate::plugin::instance::{HostCapabilities, PluginShared}; use anyhow::{Context, Result}; use clap_sys::entry::clap_plugin_entry; -use clap_sys::factory::draft::preset_discovery::{ - clap_preset_discovery_factory, CLAP_PRESET_DISCOVERY_FACTORY_ID, -}; -use clap_sys::factory::plugin_factory::{clap_plugin_factory, CLAP_PLUGIN_FACTORY_ID}; +use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory}; +use clap_sys::factory::preset_discovery::{CLAP_PRESET_DISCOVERY_FACTORY_ID, clap_preset_discovery_factory}; use clap_sys::plugin::clap_plugin_descriptor; -use clap_sys::version::clap_version; -use serde::Serialize; +use clap_sys::version::{clap_version, clap_version_is_compatible}; +use crossbeam_utils::atomic::AtomicCell; +use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::ffi::CString; +use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::ptr::NonNull; -use std::rc::Rc; - -use super::instance::Plugin; -use super::preset_discovery::PresetDiscoveryFactory; -use crate::plugin::host::Host; -use crate::util::{self, unsafe_clap_call}; +use std::thread::ThreadId; /// A CLAP plugin library built from a CLAP plugin's entry point. This can be used to iterate over /// all plugins exposed by the library and to initialize plugins. @@ -28,12 +28,16 @@ pub struct PluginLibrary { /// contained within the bundle. plugin_path: PathBuf, /// The plugin's library. Its entry point has already been initialized, and it will - /// autoamtically be deinitialized when this object gets dropped. + /// automatically be deinitialized when this object gets dropped. library: libloading::Library, + + /// To honor CLAP's thread safety guidelines, the thread this object was created from is + /// designated the 'main thread', and this object cannot be shared with other threads. + _thread: PhantomData<*const ()>, } /// Metadata for a CLAP plugin library, which may contain multiple plugins. -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct PluginLibraryMetadata { pub version: (u32, u32, u32), pub plugins: Vec, @@ -42,13 +46,15 @@ pub struct PluginLibraryMetadata { /// Metadata for a single plugin within a CLAP plugin library. See /// [plugin.h](https://github.com/free-audio/clap/blob/main/include/clap/plugin.h) for a description /// of the fields. -#[derive(Debug, Serialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] pub struct PluginMetadata { pub id: String, pub name: String, pub version: Option, pub vendor: Option, pub description: Option, + pub url: Option, pub manual_url: Option, pub support_url: Option, pub features: Vec, @@ -56,25 +62,29 @@ pub struct PluginMetadata { impl PluginMetadata { pub fn from_descriptor(descriptor: &clap_plugin_descriptor) -> Result { - Ok(PluginMetadata { - id: unsafe { util::cstr_ptr_to_mandatory_string(descriptor.id) } - .context("Error parsing the plugin descriptor's 'id' field")?, - name: unsafe { util::cstr_ptr_to_mandatory_string(descriptor.name) } - .context("Error parsing the plugin descriptor's 'name' field")?, - // Empty strings should be treated as missing values in some cases - version: unsafe { util::cstr_ptr_to_optional_string(descriptor.version) } - .context("Error parsing the plugin descriptor's 'version' field")?, - vendor: unsafe { util::cstr_ptr_to_optional_string(descriptor.vendor) } - .context("Error parsing the plugin descriptor's 'vendor' field")?, - description: unsafe { util::cstr_ptr_to_optional_string(descriptor.description) } - .context("Error parsing the plugin descriptor's 'description' field")?, - manual_url: unsafe { util::cstr_ptr_to_optional_string(descriptor.manual_url) } - .context("Error parsing the plugin descriptor's 'manual_url' field")?, - support_url: unsafe { util::cstr_ptr_to_optional_string(descriptor.support_url) } - .context("Error parsing the plugin descriptor's 'support_url' field")?, - features: unsafe { util::cstr_array_to_vec(descriptor.features)? } - .context("The plugin descriptor's 'features' array is malformed")?, - }) + unsafe { + Ok(PluginMetadata { + id: util::cstr_ptr_to_mandatory_string(descriptor.id) + .context("Error parsing the plugin descriptor's 'id' field")?, + name: util::cstr_ptr_to_mandatory_string(descriptor.name) + .context("Error parsing the plugin descriptor's 'name' field")?, + // Empty strings should be treated as missing values in some cases + version: util::cstr_ptr_to_optional_string(descriptor.version) + .context("Error parsing the plugin descriptor's 'version' field")?, + vendor: util::cstr_ptr_to_optional_string(descriptor.vendor) + .context("Error parsing the plugin descriptor's 'vendor' field")?, + description: util::cstr_ptr_to_optional_string(descriptor.description) + .context("Error parsing the plugin descriptor's 'description' field")?, + url: util::cstr_ptr_to_optional_string(descriptor.url) + .context("Error parsing the plugin descriptor's 'url' field")?, + manual_url: util::cstr_ptr_to_optional_string(descriptor.manual_url) + .context("Error parsing the plugin descriptor's 'manual_url' field")?, + support_url: util::cstr_ptr_to_optional_string(descriptor.support_url) + .context("Error parsing the plugin descriptor's 'support_url' field")?, + features: util::cstr_array_to_vec(descriptor.features)? + .context("The plugin descriptor's 'features' array is malformed")?, + }) + } } } @@ -82,9 +92,14 @@ impl Drop for PluginLibrary { fn drop(&mut self) { // The `Plugin` only exists if `init()` returned true, so we ned to deinitialize the // plugin here - let entry_point = get_clap_entry_point(&self.library) - .expect("A Plugin was constructed for a plugin with no entry point"); - unsafe_clap_call! { entry_point=>deinit() }; + let entry_point = + get_clap_entry_point(&self.library).expect("A Plugin was constructed for a plugin with no entry point"); + + let _span = Span::begin("clap_plugin_entry::deinit", ()); + + unsafe { + clap_call! { entry_point=>deinit() }; + } } } @@ -92,13 +107,17 @@ impl PluginLibrary { /// Load a CLAP plugin from a path to a `.clap` file or bundle. This will return an error if the /// plugin could not be loaded. pub fn load(path: impl AsRef) -> Result { - Self::load_with(path, |path| { - unsafe { libloading::Library::new(path) }.context("Could not load the plugin library") - }) + unsafe { + Self::load_with(path, |path| { + libloading::Library::new(path).context("Could not load the plugin library") + }) + } } /// The same as [`load()`][`Self::load()`], but with a custom library loading function. Useful /// for testing different `dlopen()` options. + /// + /// This MUST be called on the OS main thread (if applicable). pub fn load_with( path: impl AsRef, load: impl FnOnce(&Path) -> Result, @@ -113,12 +132,8 @@ impl PluginLibrary { // This is the path passed to `clap_entry::init()`. On macOS this should point to the // bundle, not the DSO. - let path_cstring = CString::new( - path.as_os_str() - .to_str() - .context("Path contains invalid UTF-8")?, - ) - .context("Path contains null bytes")?; + let path_cstring = CString::new(path.as_os_str().to_str().context("Path contains invalid UTF-8")?) + .context("Path contains null bytes")?; // NOTE: Apple says you can dlopen() bundles. This is a lie. #[cfg(not(target_os = "macos"))] @@ -128,9 +143,8 @@ impl PluginLibrary { use core_foundation::bundle::CFBundle; use core_foundation::url::CFURL; - let bundle = - CFBundle::new(CFURL::from_path(&path, true).context("Could not create CFURL")?) - .context("Could not open bundle")?; + let bundle = CFBundle::new(CFURL::from_path(&path, true).context("Could not create CFURL")?) + .context("Could not open bundle")?; let executable = bundle .executable_url() .context("Could not get executable URL within bundle")?; @@ -145,13 +159,37 @@ impl PluginLibrary { // The entry point needs to be initialized before it can be used. It will be deinitialized // when the `Plugin` object is dropped. let entry_point = get_clap_entry_point(&library)?; - if !unsafe_clap_call! { entry_point=>init(path_cstring.as_ptr()) } { + + if !clap_version_is_compatible(entry_point.clap_version) { + anyhow::bail!( + "Unsupported CLAP version ({}.{}.{})", + entry_point.clap_version.major, + entry_point.clap_version.minor, + entry_point.clap_version.revision + ); + } + + let span = Span::begin( + "clap_plugin_entry::init", + record! { + path: path_cstring.to_string_lossy().to_string() + }, + ); + + let result = unsafe { + clap_call! { entry_point=>init(path_cstring.as_ptr()) } + }; + + span.finish(record! { result: result }); + + if !result { anyhow::bail!("'clap_plugin_entry::init({path_cstring:?})' returned false."); } Ok(PluginLibrary { plugin_path: path, library, + _thread: PhantomData, }) } @@ -162,18 +200,11 @@ impl PluginLibrary { /// Get the metadata for all plugins stored in this plugin library. Most plugin libraries /// contain a single plugin, but this may return metadata for zero or more plugins. pub fn metadata(&self) -> Result { - let entry_point = get_clap_entry_point(&self.library) - .expect("A Plugin was constructed for a plugin with no entry point"); - let plugin_factory = unsafe_clap_call! { entry_point=>get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } - as *const clap_plugin_factory; - // TODO: Should we log anything here? In theory not supporting the plugin factory is - // perfectly legal, but it's a bit weird - if plugin_factory.is_null() { - anyhow::bail!( - "The plugin does not support the '{}' factory.", - CLAP_PLUGIN_FACTORY_ID.to_str().unwrap() - ); - } + let entry_point = + get_clap_entry_point(&self.library).expect("A Plugin was constructed for a plugin with no entry point"); + let plugin_factory = unsafe { + clap_call! { entry_point=>get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } + } as *const clap_plugin_factory; let mut metadata = PluginLibraryMetadata { version: ( @@ -183,14 +214,24 @@ impl PluginLibrary { ), plugins: Vec::new(), }; - let num_plugins = unsafe_clap_call! { plugin_factory=>get_plugin_count(plugin_factory) }; + + if plugin_factory.is_null() { + return Ok(metadata); + } + + let num_plugins = unsafe { + clap_call! { plugin_factory=>get_plugin_count(plugin_factory) } + }; + for i in 0..num_plugins { - let descriptor = - unsafe_clap_call! { plugin_factory=>get_plugin_descriptor(plugin_factory, i) }; + let descriptor = unsafe { + clap_call! { plugin_factory=>get_plugin_descriptor(plugin_factory, i) } + }; + if descriptor.is_null() { anyhow::bail!( - "The plugin returned a null plugin descriptor for plugin index {i} (expected \ - {num_plugins} total plugins)." + "The plugin returned a null plugin descriptor for plugin index {i} (expected {num_plugins} total \ + plugins)." ); } @@ -216,26 +257,48 @@ impl PluginLibrary { /// assert that querying a factory with a non-existent ID returns a null pointer instead of /// always returning the plugin factory. pub fn factory_exists(&self, factory_id: &str) -> bool { - let factory_id_cstring = - CString::new(factory_id).expect("The factory ID contained internal null bytes"); + let factory_id_cstring = CString::new(factory_id).expect("The factory ID contained internal null bytes"); - let entry_point = get_clap_entry_point(&self.library) - .expect("A Plugin was constructed for a plugin with no entry point"); - let factory_pointer = - unsafe_clap_call! { entry_point=>get_factory(factory_id_cstring.as_ptr()) }; + let entry_point = + get_clap_entry_point(&self.library).expect("A Plugin was constructed for a plugin with no entry point"); + + let factory_pointer = unsafe { + clap_call! { entry_point=>get_factory(factory_id_cstring.as_ptr()) } + }; !factory_pointer.is_null() } + /// Same as [`Self::create_plugin_with`] but with default [`HostCapabilities`]. + pub fn create_plugin(&self, id: &str) -> Result> { + self.create_plugin_with(id, HostCapabilities::default()) + } + /// Try to create the plugin with the given ID, and using the provided host instance. The plugin /// IDs supported by this plugin library can be found by calling /// [`metadata()`][Self::metadata()]. The returned plugin has not yet been initialized, and /// `destroy()` will be called automatically when the object is dropped. - pub fn create_plugin(&self, id: &str, host: Rc) -> Result { - let entry_point = get_clap_entry_point(&self.library) - .expect("A Plugin was constructed for a plugin with no entry point"); - let plugin_factory = unsafe_clap_call! { entry_point=>get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } - as *const clap_plugin_factory; + pub fn create_plugin_with(&self, id: &str, capabilities: HostCapabilities) -> Result> { + if OS_MAIN_THREAD.load() != Some(std::thread::current().id()) { + anyhow::bail!("Plugins must be created from the OS main thread."); + } + + let entry_point = + get_clap_entry_point(&self.library).expect("A Plugin was constructed for a plugin with no entry point"); + + let span = Span::begin( + "clap_plugin_factory::get_factory", + record!( + factory_id: CLAP_PLUGIN_FACTORY_ID.to_string_lossy() + ), + ); + + let plugin_factory = unsafe { + clap_call! { entry_point=>get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } + } as *const clap_plugin_factory; + + span.finish(record!(result: format_args!("{:p}", plugin_factory))); + if plugin_factory.is_null() { anyhow::bail!( "The plugin does not support the '{}' factory.", @@ -244,21 +307,21 @@ impl PluginLibrary { } let id_cstring = CString::new(id).context("Plugin ID contained null bytes")?; - Plugin::new(self, host, unsafe { &*plugin_factory }, &id_cstring) + unsafe { PluginShared::create_plugin(plugin_factory, &id_cstring, capabilities) } } /// Returns the plugin's preset discovery factory, if it has one. - pub fn preset_discovery_factory(&self) -> Result { - let entry_point = get_clap_entry_point(&self.library) - .expect("A Plugin was constructed for a plugin with no entry point"); - let preset_discovery_factory = unsafe_clap_call! { - entry_point=>get_factory(CLAP_PRESET_DISCOVERY_FACTORY_ID.as_ptr()) + pub fn preset_discovery_factory(&self) -> Result> { + let entry_point = + get_clap_entry_point(&self.library).expect("A Plugin was constructed for a plugin with no entry point"); + let preset_discovery_factory = unsafe { + clap_call! { + entry_point=>get_factory(CLAP_PRESET_DISCOVERY_FACTORY_ID.as_ptr()) + } } as *mut clap_preset_discovery_factory; match NonNull::new(preset_discovery_factory) { - Some(preset_discovery_factory) => { - Ok(PresetDiscoveryFactory::new(self, preset_discovery_factory)) - } + Some(preset_discovery_factory) => Ok(PresetDiscoveryFactory::new(self, preset_discovery_factory)), None => { anyhow::bail!( "The plugin does not support the '{}' factory.", @@ -283,11 +346,16 @@ impl PluginLibraryMetadata { /// Get a plugin's entry point. fn get_clap_entry_point(library: &libloading::Library) -> Result<&clap_plugin_entry> { let entry_point: libloading::Symbol<*const clap_plugin_entry> = - unsafe { library.get(b"clap_entry") } - .context("The library does not expose a 'clap_entry' symbol")?; + unsafe { library.get(b"clap_entry") }.context("The library does not expose a 'clap_entry' symbol")?; if entry_point.is_null() { anyhow::bail!("'clap_entry' is a null pointer."); } Ok(unsafe { &**entry_point }) } + +static OS_MAIN_THREAD: AtomicCell> = AtomicCell::new(None); + +pub unsafe fn mark_current_thread_as_os_main_thread() { + OS_MAIN_THREAD.store(Some(std::thread::current().id())); +} diff --git a/src/plugin/preset_discovery.rs b/src/plugin/preset_discovery.rs index 48407a4..dce62d4 100644 --- a/src/plugin/preset_discovery.rs +++ b/src/plugin/preset_discovery.rs @@ -1,22 +1,21 @@ //! An abstraction for the preset discovery factory. +use super::library::PluginLibrary; +use super::util::{self, clap_call}; use anyhow::{Context, Result}; -use clap_sys::factory::draft::preset_discovery::{ - clap_preset_discovery_factory, clap_preset_discovery_provider_descriptor, -}; +use clap_sys::factory::preset_discovery::{clap_preset_discovery_factory, clap_preset_discovery_provider_descriptor}; +use clap_sys::timestamp::{CLAP_TIMESTAMP_UNKNOWN, clap_timestamp}; use clap_sys::version::{clap_version, clap_version_is_compatible}; use std::collections::HashSet; use std::ptr::NonNull; - -use super::library::PluginLibrary; -use crate::util::{self, unsafe_clap_call}; +use time::OffsetDateTime; mod indexer; mod metadata_receiver; mod provider; -pub use self::indexer::{FileType, Flags, IndexerResults, Location, LocationValue, Soundpack}; -pub use self::metadata_receiver::{PluginAbi, Preset, PresetFile, PresetFlags}; +pub use self::indexer::{Flags, Location, LocationValue, Soundpack}; +pub use self::metadata_receiver::{PluginAbi, Preset, PresetFile}; pub use self::provider::Provider; /// A `Send+Sync` wrapper around `*const clap_preset_discovery_factory`. @@ -41,30 +40,37 @@ pub struct PresetDiscoveryFactory<'lib> { } /// Metadata (descriptor) for a preset discovery provider. These providers can be instantiated by -/// passing the IDs to [`PresetDiscoveryFactory::create()`]. +/// passing the metadata to [`PresetDiscoveryFactory::create_provider()`]. #[derive(Debug, PartialEq, Eq)] pub struct ProviderMetadata { - pub version: (u32, u32, u32), pub id: String, pub name: String, pub vendor: Option, + pub version: (u32, u32, u32), } impl ProviderMetadata { /// Parse the metadata from a `clap_preset_discovery_provider_descriptor`. - pub fn from_descriptor(descriptor: &clap_preset_discovery_provider_descriptor) -> Result { + pub unsafe fn from_descriptor(descriptor: *const clap_preset_discovery_provider_descriptor) -> Result { + anyhow::ensure!( + !descriptor.is_null(), + "The preset discovery provider descriptor is a null pointer." + ); + + let descriptor = unsafe { &*descriptor }; + Ok(ProviderMetadata { - version: ( - descriptor.clap_version.major, - descriptor.clap_version.minor, - descriptor.clap_version.revision, - ), id: unsafe { util::cstr_ptr_to_mandatory_string(descriptor.id) } .context("Error parsing the provider's 'id' field")?, name: unsafe { util::cstr_ptr_to_mandatory_string(descriptor.name) } .context("Error parsing the provider's 'name' field")?, vendor: unsafe { util::cstr_ptr_to_optional_string(descriptor.vendor) } .context("Error parsing the provider's 'vendor' field")?, + version: ( + descriptor.clap_version.major, + descriptor.clap_version.minor, + descriptor.clap_version.revision, + ), }) } @@ -81,10 +87,7 @@ impl ProviderMetadata { impl<'lib> PresetDiscoveryFactory<'lib> { /// Create a wrapper around a preset discovery factory instance returned from a CLAP plugin's /// entry point. - pub fn new( - library: &'lib PluginLibrary, - factory: NonNull, - ) -> Self { + pub fn new(library: &'lib PluginLibrary, factory: NonNull) -> Self { PresetDiscoveryFactory { handle: PresetDiscoveryHandle(factory), _library: library, @@ -101,19 +104,24 @@ impl<'lib> PresetDiscoveryFactory<'lib> { /// [`create()`][Self::create()]. pub fn metadata(&self) -> Result> { let factory = self.as_ptr(); - let num_providers = unsafe_clap_call! { factory=>count(factory) }; + let num_providers = unsafe { + clap_call! { factory=>count(factory) } + }; let mut metadata = Vec::with_capacity(num_providers as usize); for i in 0..num_providers { - let descriptor = unsafe_clap_call! { factory=>get_descriptor(factory, i) }; + let descriptor = unsafe { + clap_call! { factory=>get_descriptor(factory, i) } + }; + if descriptor.is_null() { anyhow::bail!( - "The preset discovery factory returned a null pointer for the descriptor at \ - index {i} (expected {num_providers} total providers)." + "The preset discovery factory returned a null pointer for the descriptor at index {i} (expected \ + {num_providers} total providers)." ); } - metadata.push(ProviderMetadata::from_descriptor(unsafe { &*descriptor })?); + metadata.push(unsafe { ProviderMetadata::from_descriptor(descriptor)? }); } // As a sanity check we'll make sure there are no duplicate IDs in here @@ -122,9 +130,7 @@ impl<'lib> PresetDiscoveryFactory<'lib> { .map(|provider_metadata| provider_metadata.id.as_str()) .collect(); if unique_ids.len() != metadata.len() { - anyhow::bail!( - "The preset discovery factory contains multiple entries for the same provider ID." - ); + anyhow::bail!("The preset discovery factory contains multiple entries for the same provider ID."); } Ok(metadata) @@ -134,7 +140,7 @@ impl<'lib> PresetDiscoveryFactory<'lib> { /// [`metadata()`][Self::metadata()]. /// /// Returns an error if the provider's CLAP version is not supported. - pub fn create_provider(&self, metadata: &ProviderMetadata) -> Result { + pub fn create_provider(&self, metadata: &ProviderMetadata) -> Result> { if !clap_version_is_compatible(metadata.clap_version()) { anyhow::bail!( "The preset provider with ID '{}' has an unsupported CLAP version {:?}.", @@ -146,3 +152,18 @@ impl<'lib> PresetDiscoveryFactory<'lib> { Provider::new(self, &metadata.id) } } + +/// Convert a `clap_timestamp` to an `Option`. A value of `CLAP_TIMESTAMP_UNKNOWN` +/// gets translated to `None`. +pub fn parse_timestamp(timestamp: clap_timestamp) -> Result> { + let parsed = if timestamp == CLAP_TIMESTAMP_UNKNOWN { + None + } else { + Some( + OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128 * 1_000_000) + .map_err(|_| anyhow::anyhow!("Could not parse the timestamp."))?, + ) + }; + + Ok(parsed) +} diff --git a/src/plugin/preset_discovery/indexer.rs b/src/plugin/preset_discovery/indexer.rs index e906d01..dededd7 100644 --- a/src/plugin/preset_discovery/indexer.rs +++ b/src/plugin/preset_discovery/indexer.rs @@ -1,46 +1,29 @@ //! The indexer abstraction for a CLAP plugin's preset discovery factory. During initialization the //! plugin fills this object with its supported locations, file types, and sound packs. +use crate::cli::fail_test; +use crate::cli::tracing::{Recordable, Recorder}; +use crate::plugin::preset_discovery::parse_timestamp; +use crate::plugin::util::{self, CHECK_POINTER, Proxy, Proxyable, cstr_ptr_to_string, validator_version}; use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use std::cell::RefCell; -use std::ffi::{c_char, c_void, CStr, CString}; +use clap_sys::factory::preset_discovery::*; +use clap_sys::version::CLAP_VERSION; +use serde::{Deserialize, Serialize}; +use std::ffi::{CString, c_char, c_void}; use std::fmt::Display; -use std::path::Path; -use std::pin::Pin; +use std::path::PathBuf; +use std::sync::Mutex; use std::thread::ThreadId; - -use clap_sys::factory::draft::preset_discovery::{ - clap_preset_discovery_filetype, clap_preset_discovery_indexer, clap_preset_discovery_location, - clap_preset_discovery_location_kind, clap_preset_discovery_soundpack, - CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT, CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT, - CLAP_PRESET_DISCOVERY_IS_FAVORITE, CLAP_PRESET_DISCOVERY_IS_USER_CONTENT, - CLAP_PRESET_DISCOVERY_LOCATION_FILE, CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN, -}; -use clap_sys::version::CLAP_VERSION; -use parking_lot::Mutex; - -use crate::util::{self, check_null_ptr}; +use time::OffsetDateTime; #[derive(Debug)] pub struct Indexer { /// The thread ID for the thread this object was created on. This object is not thread-safe, so /// we'll assert that all callbacks are made from this thread. expected_thread_id: ThreadId, - /// A description of the first error encountered by this `Indexer`, if any. This is used to - /// store thread safety errors and other errors as the result of callbacks. In those cases we - /// can only handle the error after the callback has been mode. - callback_error: RefCell>, /// The data written to this object by the plugin. - results: RefCell, - - /// The validator's version, reported in the `clap_preset_discovery_indexer` struct. - _clap_validator_version: CString, - /// The vtable that's passed to the provider. The `indexer_data` field is populated with a - /// pointer to this object. - clap_preset_discovery_indexer: Mutex, + result: Mutex>, } /// The data written to the indexer by the plugin during the @@ -58,7 +41,9 @@ pub struct IndexerResults { /// Data parsed from a `clap_preset_discovery_filetype`. #[derive(Debug, Clone)] pub struct FileType { + #[allow(unused)] pub name: String, + #[allow(unused)] pub description: Option, /// The file extension, doesn't contain a leading period. pub extension: String, @@ -66,7 +51,10 @@ pub struct FileType { impl FileType { /// Parse a `clap_preset_discovery_fileType`, returning an error if the data is not valid. - pub fn from_descriptor(descriptor: &clap_preset_discovery_filetype) -> Result { + pub unsafe fn from_descriptor(descriptor: *const clap_preset_discovery_filetype) -> Result { + anyhow::ensure!(!descriptor.is_null(), "Filetype is null"); + let descriptor = unsafe { &*descriptor }; + let file_type = FileType { name: unsafe { util::cstr_ptr_to_mandatory_string(descriptor.name) } .context("Error parsing the file extension's 'name' field")?, @@ -98,7 +86,7 @@ pub struct Location { pub value: LocationValue, } -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Flags { pub is_factory_content: bool, @@ -107,54 +95,24 @@ pub struct Flags { pub is_favorite: bool, } -impl Display for Flags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut is_first_flag = true; - - if self.is_factory_content { - write!(f, "factory content")?; - is_first_flag = false; - } - if self.is_user_content { - if is_first_flag { - write!(f, "user content")?; - } else { - write!(f, ", user content")?; - } - is_first_flag = false; - } - if self.is_demo_content { - if is_first_flag { - write!(f, "demo content")?; - } else { - write!(f, ", demo content")?; - } - is_first_flag = false; - } - if self.is_favorite { - if is_first_flag { - write!(f, "favorite")?; - } else { - write!(f, ", favorite")?; - } - is_first_flag = false; - } - - if is_first_flag { - write!(f, "(none)")?; - } - - Ok(()) +impl Recordable for Flags { + fn record(&self, record: &mut dyn Recorder) { + record.record("is_factory_content", self.is_factory_content); + record.record("is_user_content", self.is_user_content); + record.record("is_demo_content", self.is_demo_content); + record.record("is_favorite", self.is_favorite); } } impl Location { /// Parse a `clap_preset_discovery_location`, returning an error if the data is not valid. - pub fn from_descriptor(descriptor: &clap_preset_discovery_location) -> Result { + pub unsafe fn from_descriptor(descriptor: *const clap_preset_discovery_location) -> Result { + anyhow::ensure!(!descriptor.is_null(), "Location is null"); + let descriptor = unsafe { &*descriptor }; + Ok(Location { flags: Flags { - is_factory_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) - != 0, + is_factory_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) != 0, is_user_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_USER_CONTENT) != 0, is_demo_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT) != 0, is_favorite: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FAVORITE) != 0, @@ -171,7 +129,7 @@ impl Location { /// A location as used by the preset discovery API. These are used to refer to single files, /// directories, and internal plugin data. Previous versions of the API used URIs instead of a /// location kind and a location path field. -#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)] +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize)] pub enum LocationValue { /// An absolute path to a file or a directory. The spec says nothing about trailing slashes, but /// the paths must at least be absolute. @@ -187,34 +145,17 @@ pub enum LocationValue { impl Display for LocationValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - LocationValue::File(path) => { - write!(f, "CLAP_PRESET_DISCOVERY_LOCATION_FILE with path {path:?}") - } - LocationValue::Internal => write!(f, "CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN"), + LocationValue::File(path) => write!(f, "{}", path.to_string_lossy()), + LocationValue::Internal => write!(f, ""), } } } -impl Serialize for LocationValue { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { +impl Recordable for LocationValue { + fn record(&self, record: &mut dyn Recorder) { match self { - LocationValue::File(path) => serializer.serialize_newtype_variant( - "LocationValue", - 1, - "CLAP_PRESET_DISCOVERY_LOCATION_FILE", - // This should have alreayd been checked at this point - path.to_str().expect("Invalid UTF-8"), - ), - LocationValue::Internal => serializer.serialize_newtype_variant( - "LocationValue", - 1, - "CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN", - // This should just resolve to a `null` value, to keep the format consistent - &None::<()>, - ), + LocationValue::File(path) => path.to_string_lossy().record(record), + LocationValue::Internal => "".record(record), } } } @@ -223,35 +164,26 @@ impl LocationValue { /// Constructs an new [`LocationValue`] from a location kind and a location field. Whether this /// succeeds or not depends on the location kind and whether or not the location is a null /// pointer or not. See the preset discovery factory definition for more information. - pub unsafe fn new( - location_kind: clap_preset_discovery_location_kind, - location: *const c_char, - ) -> Result { + pub unsafe fn new(location_kind: clap_preset_discovery_location_kind, location: *const c_char) -> Result { match location_kind { CLAP_PRESET_DISCOVERY_LOCATION_FILE => { if location.is_null() { - anyhow::bail!( - "The location may not be a null pointer with \ - CLAP_PRESET_DISCOVERY_LOCATION_FILE." - ) + anyhow::bail!("The location may not be a null pointer with CLAP_PRESET_DISCOVERY_LOCATION_FILE.") } - let path = CStr::from_ptr(location); - let path_str = path - .to_str() - .context("Invalid UTF-8 in preset discovery location")?; + let path_str = unsafe { cstr_ptr_to_string(location) } + .context("Error parsing the location string for a file location")? + .unwrap_or_default(); + if !path_str.starts_with('/') { anyhow::bail!("'{path_str}' should be an absolute path, i.e. '/{path_str}'."); } - Ok(LocationValue::File(path.to_owned())) + Ok(LocationValue::File(CString::new(path_str).unwrap())) } CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN => { if !location.is_null() { - anyhow::bail!( - "The location must be a null pointer with \ - CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN." - ) + anyhow::bail!("The location must be a null pointer with CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN.") } Ok(LocationValue::Internal) @@ -267,33 +199,35 @@ impl LocationValue { /// The returned pointer is valid for the lifetime of this struct. pub fn to_raw(&self) -> (clap_preset_discovery_location_kind, *const c_char) { match self { - LocationValue::File(path) => (CLAP_PRESET_DISCOVERY_LOCATION_FILE, path.as_ptr()), + LocationValue::File(path) => (CLAP_PRESET_DISCOVERY_LOCATION_FILE, path.as_ptr() as *const c_char), LocationValue::Internal => (CLAP_PRESET_DISCOVERY_LOCATION_PLUGIN, std::ptr::null()), } } + pub fn file_path(&self) -> Option { + match self { + LocationValue::File(path) => Some(PathBuf::from(path.to_string_lossy().to_string())), + LocationValue::Internal => None, + } + } + /// Get a file name (only the base name) for this location. For internal presets this returns /// ``. pub fn file_name(&self) -> Result { - match self { - LocationValue::File(path) => { - let path = Path::new(path.to_str().context("Invalid UTF-8 in file path")?); - - Ok(path - .file_name() - .with_context(|| format!("{path:?} is not a valid preset path"))? - .to_str() - .unwrap() - .to_owned()) - } - LocationValue::Internal => Ok(String::from("")), + match self.file_path() { + None => Ok(String::from("")), + Some(path) => Ok(path + .file_name() + .with_context(|| format!("{path:?} does not have a valid file name"))? + .to_string_lossy() + .to_string()), } } } /// Data parsed from a `clap_preset_discovery_soundpack`. All of these fields except for the ID may /// be empty. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Soundpack { pub flags: Flags, @@ -305,16 +239,18 @@ pub struct Soundpack { pub homepage_url: Option, pub vendor: Option, pub image_path: Option, - pub release_timestamp: Option>, + pub release_timestamp: Option, } impl Soundpack { /// Parse a `clap_preset_discovery_soundpack`, returning an error if the data is not valid. - pub fn from_descriptor(descriptor: &clap_preset_discovery_soundpack) -> Result { + pub unsafe fn from_descriptor(descriptor: *const clap_preset_discovery_soundpack) -> Result { + anyhow::ensure!(!descriptor.is_null(), "Soundpack is null"); + let descriptor = unsafe { &*descriptor }; + Ok(Soundpack { flags: Flags { - is_factory_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) - != 0, + is_factory_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) != 0, is_user_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_USER_CONTENT) != 0, is_demo_content: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT) != 0, is_favorite: (descriptor.flags & CLAP_PRESET_DISCOVERY_IS_FAVORITE) != 0, @@ -332,203 +268,171 @@ impl Soundpack { .context("Error parsing the soundpack's 'vendor' field")?, image_path: unsafe { util::cstr_ptr_to_optional_string(descriptor.image_path) } .context("Error parsing the soundpack's 'image_path' field")?, - release_timestamp: util::parse_timestamp(descriptor.release_timestamp) + release_timestamp: parse_timestamp(descriptor.release_timestamp) .context("Error parsing the soundpack's 'release_timestamp' field")?, }) } } -impl Drop for Indexer { - fn drop(&mut self) { - // The results will have been moved out of `self.results` when initializing the provider, so - // if this does contain values then the plugin did something shady - let results = self.results.borrow(); - if !results.file_types.is_empty() - || !results.locations.is_empty() - || !results.soundpacks.is_empty() - { - log::warn!( - "The plugin declared more file types, locations, or soundpacks after its \ - initialization. This is invalid behavior, but there is currently no test to \ - check for this." - ) - } - - if let Some(error) = self.callback_error.borrow_mut().take() { - log::error!( - "The validator's 'clap_preset_indexer' has detected an error during a callback \ - that is going to be thrown away. This is a clap-validator bug. The error message \ - is: {error}" - ) +impl Proxyable for Indexer { + type Vtable = clap_preset_discovery_indexer; + + fn init(&self) -> Self::Vtable { + clap_preset_discovery_indexer { + clap_version: CLAP_VERSION, + indexer_data: CHECK_POINTER, + name: c"clap-validator".as_ptr(), + vendor: c"Robbert van der Helm".as_ptr(), + url: c"https://github.com/free-audio/clap-validator".as_ptr(), + version: validator_version().as_ptr(), + declare_filetype: Some(Self::declare_filetype), + declare_location: Some(Self::declare_location), + declare_soundpack: Some(Self::declare_soundpack), + get_extension: Some(Self::get_extension), } } } impl Indexer { - pub fn new() -> Pin> { - let clap_validator_version = - CString::new(env!("CARGO_PKG_VERSION")).expect("Invalid bytes in crate version"); - let indexer = Box::pin(Self { + pub fn new() -> Proxy { + Proxy::new(Self { expected_thread_id: std::thread::current().id(), - callback_error: RefCell::new(None), - - results: RefCell::default(), - - clap_preset_discovery_indexer: Mutex::new(clap_preset_discovery_indexer { - clap_version: CLAP_VERSION, - name: b"clap-validator\0".as_ptr() as *const c_char, - vendor: b"Robbert van der Helm\0".as_ptr() as *const c_char, - url: b"https://github.com/free-audio/clap-validator\0".as_ptr() as *const c_char, - version: clap_validator_version.as_ptr(), - // This is filled with a pointer to this struct after the `Box` has been allocated - indexer_data: std::ptr::null_mut(), - declare_filetype: Some(Self::declare_filetype), - declare_location: Some(Self::declare_location), - declare_soundpack: Some(Self::declare_soundpack), - get_extension: Some(Self::get_extension), - }), - _clap_validator_version: clap_validator_version, - }); - - indexer.clap_preset_discovery_indexer.lock().indexer_data = - &*indexer as *const Self as *mut c_void; - - indexer - } - - /// Get a `clap_preset_discovery_indexer` vtable pointer that can be passed to the - /// `clap_preset_discovery_factory` when creating a provider. - pub fn clap_preset_discovery_indexer_ptr( - self: &Pin>, - ) -> *const clap_preset_discovery_indexer { - self.clap_preset_discovery_indexer.data_ptr() + result: Mutex::new(Ok(IndexerResults::default())), + }) } /// Get the values written to this indexer by the plugin during the - /// `clap_preset_discovery_provider::init()` call. Returns any error that would be returned by - /// [`callback_error_check()`][Self::callback_error_check()]. + /// `clap_preset_discovery_provider::init()` call. This also checks for errors that + /// happened during the indexer callbacks. /// - /// This moves the values out of this object. - pub fn results(&self) -> Result { - self.callback_error_check()?; - - Ok(std::mem::take(&mut self.results.borrow_mut())) + /// This can only be called once. + pub fn finish(&self) -> Result { + std::mem::replace( + &mut *self.result.lock().unwrap(), + Err(anyhow::anyhow!("Indexer already finished")), + ) } - /// Check whether errors happened during the plugin's callbacks. Returns the first error if - /// there were any. Automatically called when calling [`results()`][Self::results()]. If there - /// are errors and this function is not called before the object is destroyed, an error will be - /// logged. - pub fn callback_error_check(&self) -> Result<()> { - match self.callback_error.borrow_mut().take() { - Some(err) => anyhow::bail!(err), - None => Ok(()), + #[track_caller] + fn wrap( + indexer: *const clap_preset_discovery_indexer, + function_name: &'static str, + f: impl FnOnce(&Self) -> Result, + ) -> Option { + let state = unsafe { + Proxy::::from_vtable(indexer).unwrap_or_else(|e| { + fail_test!("{}: {}", function_name, e); + }) + }; + + if Proxy::vtable(&state).indexer_data != CHECK_POINTER { + fail_test!("{}: plugin messed with the 'indexer_data' pointer", function_name); + } + + match f(&state) { + Ok(result) => Some(result), + Err(error) => { + log::error!("{:#}", error); + + let mut guard = state.result.lock().unwrap(); + if guard.is_ok() { + *guard = Err(error.context(function_name.to_string())); + } + + None + } } } /// Checks that this function is called from the same thread the indexer was created on. If it /// is not, then an error indicating this can be retrieved using - /// [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread safety errors + /// [`check_errors()`][Self::check_errors()]. Subsequent thread safety errors /// will not overwrite earlier ones. - fn assert_same_thread(&self, function_name: &str) { + fn assert_same_thread(&self) -> Result<()> { let current_thread_id = std::thread::current().id(); - if current_thread_id != self.expected_thread_id { - self.set_callback_error(format!( - "'{}' may only be called from the same thread the 'clap_preset_indexer' was \ - created on (thread {:?}), but it was called from thread {:?}", - function_name, self.expected_thread_id, current_thread_id - )); - } - } + anyhow::ensure!( + current_thread_id == self.expected_thread_id, + "A 'clap_preset_indexer::*' method may only be called from the same thread the 'clap_preset_indexer' was \ + created on (thread {:?}), but it was called from thread {:?}", + self.expected_thread_id, + current_thread_id + ); - /// Set the callback error field if it does not already contain a value. Earlier errors are not - /// overwritten. - fn set_callback_error(&self, error: impl Into) { - let mut callback_error = self.callback_error.borrow_mut(); - if callback_error.is_none() { - *callback_error = Some(error.into()); - } + Ok(()) } unsafe extern "C" fn declare_filetype( indexer: *const clap_preset_discovery_indexer, filetype: *const clap_preset_discovery_filetype, ) -> bool { - check_null_ptr!(indexer, (*indexer).indexer_data, filetype); - let this = &*((*indexer).indexer_data as *const Self); - - this.assert_same_thread("clap_preset_discovery_indexer::declare_filetype()"); - match FileType::from_descriptor(&*filetype) { - Ok(file_type) => { - this.results.borrow_mut().file_types.push(file_type); - - true - } - Err(err) => { - this.set_callback_error(format!( - "Error in 'clap_preset_discovery_indexer::declare_filetype()' call: {err:#}" - )); - - false - } - } + Self::wrap(indexer, "clap_preset_discovery_indexer::declare_filetype", |this| { + this.assert_same_thread()?; + + let mut results = this.result.lock().unwrap(); + let Ok(results) = results.as_mut() else { + // The indexer has already been finished, or an error has occurred + // If the error has already occurred, we wont overwrite it + anyhow::bail!("Attempt to add to the indexer after the 'clap_preset_discovery_factory::init' call"); + }; + + results.file_types.push(unsafe { FileType::from_descriptor(filetype)? }); + Ok(true) + }) + .unwrap_or(false) } unsafe extern "C" fn declare_location( indexer: *const clap_preset_discovery_indexer, location: *const clap_preset_discovery_location, ) -> bool { - check_null_ptr!(indexer, (*indexer).indexer_data, location); - let this = &*((*indexer).indexer_data as *const Self); + Self::wrap(indexer, "clap_preset_discovery_indexer::declare_location", |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_indexer::declare_location()"); - match Location::from_descriptor(&*location) { - Ok(location) => { - this.results.borrow_mut().locations.push(location); - - true - } - Err(err) => { - this.set_callback_error(format!( - "Error in 'clap_preset_discovery_indexer::declare_location()' call: {err:#}" - )); + let mut results = this.result.lock().unwrap(); + let Ok(results) = results.as_mut() else { + // Same as above + anyhow::bail!("Attempt to add to the indexer after the 'clap_preset_discovery_factory::init' call"); + }; - false - } - } + results.locations.push(unsafe { Location::from_descriptor(location)? }); + Ok(true) + }) + .unwrap_or(false) } unsafe extern "C" fn declare_soundpack( indexer: *const clap_preset_discovery_indexer, soundpack: *const clap_preset_discovery_soundpack, ) -> bool { - check_null_ptr!(indexer, (*indexer).indexer_data, soundpack); - let this = &*((*indexer).indexer_data as *const Self); - - this.assert_same_thread("clap_preset_discovery_indexer::declare_soundpack()"); - match Soundpack::from_descriptor(&*soundpack) { - Ok(soundpack) => { - this.results.borrow_mut().soundpacks.push(soundpack); - - true - } - Err(err) => { - this.set_callback_error(format!( - "Error in 'clap_preset_discovery_indexer::declare_soundpack()' call: {err:#}" - )); - - false - } - } + Self::wrap(indexer, "clap_preset_discovery_indexer::declare_soundpack", |this| { + this.assert_same_thread()?; + + let mut results = this.result.lock().unwrap(); + let Ok(results) = results.as_mut() else { + // Same as above + anyhow::bail!("Attempt to add to the indexer after the 'clap_preset_discovery_factory::init' call"); + }; + + results + .soundpacks + .push(unsafe { Soundpack::from_descriptor(soundpack)? }); + Ok(true) + }) + .unwrap_or(false) } unsafe extern "C" fn get_extension( indexer: *const clap_preset_discovery_indexer, extension_id: *const c_char, ) -> *const c_void { - check_null_ptr!(indexer, (*indexer).indexer_data, extension_id); + Self::wrap(indexer, "clap_preset_discovery_indexer::get_extension", |_| { + if extension_id.is_null() { + anyhow::bail!("Null extension ID"); + } - // There are currently no extensions for the preset discovery factory - std::ptr::null() + // There are currently no extensions for the preset discovery factory + Ok(std::ptr::null()) + }) + .unwrap_or_default() } } diff --git a/src/plugin/preset_discovery/metadata_receiver.rs b/src/plugin/preset_discovery/metadata_receiver.rs index c12ce52..2e9a565 100644 --- a/src/plugin/preset_discovery/metadata_receiver.rs +++ b/src/plugin/preset_discovery/metadata_receiver.rs @@ -2,24 +2,22 @@ //! querying metadata for a plugin's file. This is sort of like a state machine the plugin writes //! one or more presets to. +use super::{Flags, LocationValue}; +use crate::cli::fail_test; +use crate::plugin::preset_discovery::parse_timestamp; +use crate::plugin::util::{self, CHECK_POINTER, Proxy, Proxyable}; use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use clap_sys::factory::draft::preset_discovery::{ - clap_plugin_id, clap_preset_discovery_metadata_receiver, clap_timestamp, - CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT, CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT, - CLAP_PRESET_DISCOVERY_IS_FAVORITE, CLAP_PRESET_DISCOVERY_IS_USER_CONTENT, -}; -use parking_lot::Mutex; -use serde::Serialize; +use clap_sys::factory::preset_discovery::*; +use clap_sys::timestamp::clap_timestamp; +use clap_sys::universal_plugin_id::clap_universal_plugin_id; +use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::collections::BTreeMap; -use std::ffi::{c_char, c_void}; +use std::ffi::c_char; use std::fmt::Display; -use std::pin::Pin; +use std::sync::Mutex; use std::thread::ThreadId; - -use super::{Flags, LocationValue}; -use crate::util::{self, check_null_ptr}; +use time::OffsetDateTime; /// An implementation of the preset discovery's metadata receiver. This borrows a /// `Result` because the important work is done when this object is dropped. When this @@ -35,7 +33,7 @@ use crate::util::{self, check_null_ptr}; /// /// IO errors returned by the plugin are treated as hard errors for now. #[derive(Debug)] -pub struct MetadataReceiver<'a> { +pub struct MetadataReceiver { /// The thread ID for the thread this object was created on. This object is not thread-safe, so /// we'll assert that all callbacks are made from this thread. expected_thread_id: ThreadId, @@ -43,18 +41,16 @@ pub struct MetadataReceiver<'a> { /// The location this metadata receiver was created for. If this is a single-file preset and a /// name has not been explicitly set, then the preset's name becomes the file name including the /// file extensions. - location: &'a LocationValue, + location: LocationValue, /// The crawled location's flags. This is used as a fallback for the preset flags if the /// provider does not explicitly set flags for a preset. location_flags: Flags, + /// See this object's docstring. If an error occurs, then the error is written here immediately. /// If the object is dropped and all presets have been written to `pending_presets` without any /// errors occurring, then this will contain a [`PresetFile`] describing the preset(s) added by /// the plugin. - /// - /// Stored in a `RefCell` in the off chance that the plugin doesn't use this in a thread safe - /// way. - result: RefCell<&'a mut Option>>, + result: Mutex>>, /// The data for the next preset. This is `None` until the plugin starts calling one of the data /// setter functions. After that point the preset's data is filled in piece by piece like in a @@ -68,14 +64,10 @@ pub struct MetadataReceiver<'a> { /// on the presence of `load_key`. If this is not set, then subsequent `begin_preset()` calls /// are treated as errors. Used in `maybe_write_preset()`. next_load_key: RefCell>, - - /// The vtable that's passed to the provider. The `receiver_data` field is populated with a - /// pointer to this object. - clap_preset_discovery_metadata_receiver: Mutex, } /// One or more presets declared by the plugin through a preset provider metadata receiver. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PresetFile { Single(Preset), @@ -95,8 +87,8 @@ struct PartialPreset { pub flags: Option, pub creators: Vec, pub description: Option, - pub creation_time: Option>, - pub modification_time: Option>, + pub creation_time: Option, + pub modification_time: Option, pub features: Vec, pub extra_info: BTreeMap, } @@ -122,10 +114,7 @@ impl PartialPreset { /// no flags set for this preset, then the location's flags will be used. pub fn finalize(self, location_flags: &Flags) -> Result { if self.plugin_ids.is_empty() { - anyhow::bail!( - "The preset '{}' was defined without setting a plugin ID.", - self.name - ); + anyhow::bail!("The preset '{}' was defined without setting a plugin ID.", self.name); } Ok(Preset { @@ -133,8 +122,14 @@ impl PartialPreset { plugin_ids: self.plugin_ids, soundpack_id: self.soundpack_id, flags: match self.flags { - Some(flags) => PresetFlags::Explicit(flags), - None => PresetFlags::Inherited(*location_flags), + Some(flags) => PresetFlags { + flags, + is_inherited: false, + }, + None => PresetFlags { + flags: *location_flags, + is_inherited: true, + }, }, creators: self.creators, description: self.description, @@ -148,7 +143,7 @@ impl PartialPreset { /// The docs specify that you are not allowed to specify a preset name unless the preset is part of /// a container file. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", tag = "type", content = "value")] pub enum PresetName { Explicit(String), @@ -166,26 +161,13 @@ impl Display for PresetName { /// The plugin ABI the preset was defined for. Most plugins will define only presets for CLAP /// plugins. -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct PluginId { - #[serde(serialize_with = "plugin_abi_to_string")] pub abi: PluginAbi, pub id: String, } -/// Always serialize this as a string. Having the `Other` enum variant is nice but it looks out of -/// place in the JSON output. -fn plugin_abi_to_string(plugin_abi: &PluginAbi, ser: S) -> Result -where - S: serde::Serializer, -{ - match plugin_abi { - PluginAbi::Clap => "clap".serialize(ser), - PluginAbi::Other(s) => s.serialize(ser), - } -} - /// The plugin ABI the preset was defined for. Most plugins will define only presets for CLAP /// plugins. #[derive(Debug, Clone, PartialEq)] @@ -194,8 +176,34 @@ pub enum PluginAbi { Other(String), } +impl Serialize for PluginAbi { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + PluginAbi::Clap => serializer.serialize_str("clap"), + PluginAbi::Other(abi) => serializer.serialize_str(abi), + } + } +} + +impl<'de> Deserialize<'de> for PluginAbi { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + if s == "clap" { + Ok(PluginAbi::Clap) + } else { + Ok(PluginAbi::Other(s)) + } + } +} + /// A preset as declared by the plugin. Constructed from a [`PartialPreset`]. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Preset { pub name: PresetName, @@ -204,140 +212,104 @@ pub struct Preset { pub flags: PresetFlags, pub creators: Vec, pub description: Option, - pub creation_time: Option>, - pub modification_time: Option>, + + pub creation_time: Option, + pub modification_time: Option, + pub features: Vec, pub extra_info: BTreeMap, } /// The flags applying to a preset. These are either explicitly set for the preset or inherited from /// the location. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "kebab-case", tag = "type")] -pub enum PresetFlags { - /// The fall back to the location's flags if the provider did not explicitly set flags for the - /// preset. - Inherited(Flags), - /// Flags that were explicitly set for the preset. - Explicit(Flags), +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct PresetFlags { + #[serde(flatten)] + pub flags: Flags, + pub is_inherited: bool, } -impl Display for PresetFlags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PresetFlags::Inherited(flags) => write!(f, "{flags} (inherited)"), - PresetFlags::Explicit(flags) => flags.fmt(f), +impl Proxyable for MetadataReceiver { + type Vtable = clap_preset_discovery_metadata_receiver; + + fn init(&self) -> Self::Vtable { + clap_preset_discovery_metadata_receiver { + receiver_data: CHECK_POINTER, + on_error: Some(Self::on_error), + begin_preset: Some(Self::begin_preset), + add_plugin_id: Some(Self::add_plugin_id), + set_soundpack_id: Some(Self::set_soundpack_id), + set_flags: Some(Self::set_flags), + add_creator: Some(Self::add_creator), + set_description: Some(Self::set_description), + set_timestamps: Some(Self::set_timestamps), + add_feature: Some(Self::add_feature), + add_extra_info: Some(Self::add_extra_info), } } } -impl Preset { - /// Format the supported plugin IDs as a comma separated string. CLAP plugins are listed as is, - /// plugins for other ABIs will have the ABI prepended to them. - pub fn plugin_ids_string(&self) -> String { - let plugin_id_strings: Vec<_> = self - .plugin_ids - .iter() - .map(|plugin_id| match &plugin_id.abi { - PluginAbi::Clap => plugin_id.id.to_owned(), - PluginAbi::Other(abi) => format!("{}: {}", abi, plugin_id.id), - }) - .collect(); - - plugin_id_strings.join(", ") - } -} - -impl Drop for MetadataReceiver<'_> { - fn drop(&mut self) { - // If the plugin declared a(nother) preset file, then this will be added to `self.result` - // now. If an error occurred at any point, then the result will instead contain that error. - self.maybe_write_preset(); - } -} - -impl<'a> MetadataReceiver<'a> { - /// Create a new metadata receiver that will write the results to the provided `result`. This is - /// needed because the actual writing happens when this object is dropped. After that point - /// `result` is either: - /// - /// - `None` if the plugin didn't write any presets. - /// - `Some(Err(err))` if an error occurred while declaring presets. - /// - `Some(Ok(preset_file))` if the plugin declared one or more presets successfully. - pub fn new( - result: &'a mut Option>, - location: &'a LocationValue, - location_flags: Flags, - ) -> Pin> { - // In the event that the caller reuses result objects this needs to be initialized to a - // non-error value, since if it does contain an error at some point then nothing will be - // written to it in the `Drop` implementation - *result = None; - - let metadata_receiver = Box::pin(Self { +impl MetadataReceiver { + /// Create a new metadata receiver. + pub fn new(location: LocationValue, location_flags: Flags) -> Proxy { + Proxy::new(Self { expected_thread_id: std::thread::current().id(), location, location_flags, - result: RefCell::new(result), + result: Mutex::new(Ok(None)), next_preset_data: RefCell::new(None), next_load_key: RefCell::new(None), - - clap_preset_discovery_metadata_receiver: Mutex::new( - clap_preset_discovery_metadata_receiver { - // This is set to a pointer to this pinned data structure later - receiver_data: std::ptr::null_mut(), - on_error: Some(Self::on_error), - begin_preset: Some(Self::begin_preset), - add_plugin_id: Some(Self::add_plugin_id), - set_soundpack_id: Some(Self::set_soundpack_id), - set_flags: Some(Self::set_flags), - add_creator: Some(Self::add_creator), - set_description: Some(Self::set_description), - set_timestamps: Some(Self::set_timestamps), - add_feature: Some(Self::add_feature), - add_extra_info: Some(Self::add_extra_info), - }, - ), - }); - - metadata_receiver - .clap_preset_discovery_metadata_receiver - .lock() - .receiver_data = &*metadata_receiver as *const Self as *mut c_void; - - metadata_receiver + }) } - /// Get a `clap_preset_discovery_metadata_receiver` vtable pointer that can be passed to the - /// `clap_preset_discovery_factory` when creating a provider. - pub fn clap_preset_discovery_metadata_receiver_ptr( - self: &Pin>, - ) -> *const clap_preset_discovery_metadata_receiver { - self.clap_preset_discovery_metadata_receiver.data_ptr() + /// Finish the preset declaration process and return the result. This finishes any pending + /// presets and returns the [`PresetFile`]. + pub fn finish(&self) -> Result> { + self.flush_preset()?; + std::mem::replace(&mut *self.result.lock().unwrap(), Ok(None)) } - /// Checks that this function is called from the same thread the indexer was created on. If it - /// is not, then an error indicating this can be retrieved using - /// [`callback_error_check()`][Self::callback_error_check()]. Subsequent thread safety errors - /// will not overwrite earlier ones. - fn assert_same_thread(&self, function_name: &str) { + /// Checks that this function is called from the same thread the indexer was created on. + fn assert_same_thread(&self) -> Result<()> { let current_thread_id = std::thread::current().id(); - if current_thread_id != self.expected_thread_id { - self.set_callback_error(format!( - "'{}' may only be called from the same thread the 'clap_preset_indexer' was \ - created on (thread {:?}), but it was called from thread {:?}", - function_name, self.expected_thread_id, current_thread_id - )); - } + anyhow::ensure!( + current_thread_id == self.expected_thread_id, + "'clap_preset_discovery_metadata_receiver' methods may only be called from the same thread the \ + 'clap_preset_indexer' was created on (thread {:?}), but it was called from thread {:?}", + self.expected_thread_id, + current_thread_id + ); + Ok(()) } - /// Write an error to the result field if it did not already contain a value. Earlier errors are - /// not overwritten. - fn set_callback_error(&self, error: impl Into) { - match &mut *self.result.borrow_mut() { - Some(Err(_)) => (), - result => **result = Some(Err(anyhow::anyhow!(error.into()))), + #[track_caller] + fn wrap( + receiver: *const clap_preset_discovery_metadata_receiver, + function_name: &str, + f: impl FnOnce(&Self) -> Result, + ) -> Option { + let state = unsafe { + Proxy::::from_vtable(receiver).unwrap_or_else(|e| { + fail_test!("{}: {}", function_name, e); + }) + }; + + if Proxy::vtable(&state).receiver_data != CHECK_POINTER { + fail_test!("{}: plugin messed with the 'receiver_data' pointer", function_name); + } + + match f(&state) { + Ok(result) => Some(result), + Err(error) => { + let mut guard = state.result.lock().unwrap(); + if guard.is_ok() { + *guard = Err(error.context(function_name.to_string())); + } + + None + } } } @@ -346,41 +318,33 @@ impl<'a> MetadataReceiver<'a> { /// depending on whether a load key was passed to the `begin_preset()` function. If multiple /// presets are written for a single-file preset, then an error will be written to the result. /// If an error was previously written, then it will not be overwritten. - fn maybe_write_preset(&self) { - if let Some(partial_preset) = self.next_preset_data.borrow_mut().take() { - match ( - &mut *self.result.borrow_mut(), - partial_preset.finalize(&self.location_flags), - // The `take()` is important here to catch the situation where the plugin adds a - // load key on the first `begin_preset()` call but not in subsequent calls - self.next_load_key.borrow_mut().take(), - ) { - // If an error was already produced then it should be preserved, and new errors - // should be written to the Result if there wasn't already one - (Some(Err(_)), _, _) => (), - (_, Err(err), _) => self.set_callback_error(format!("{err:#}")), - (result @ None, Ok(preset), None) => { - **result = Some(Ok(PresetFile::Single(preset))) - } - (result @ None, Ok(preset), Some(load_key)) => { - let mut presets = BTreeMap::new(); - presets.insert(load_key, preset); + fn flush_preset(&self) -> Result<()> { + let Some(partial_preset) = self.next_preset_data.borrow_mut().take() else { + return Ok(()); // No preset to flush + }; - **result = Some(Ok(PresetFile::Container(presets))); - } - (Some(Ok(PresetFile::Container(presets))), Ok(preset), Some(load_key)) => { - presets.insert(load_key, preset); - } - // These situations have been caught in `begin_preset()`. If a second preset has - // been started when the first preset didn't have a load key this is a validator - // bug. - (Some(Ok(PresetFile::Single(_))), Ok(_), _) - | (Some(Ok(PresetFile::Container(_))), Ok(_), None) => unreachable!( - "Inconsistent state in the validator's metadata receiver found, this is a \ - clap-validator bug." - ), + let mut result = self.result.lock().unwrap(); + let Ok(result) = result.as_mut() else { + return Ok(()); // An error was already produced, no one cares + }; + + let preset = partial_preset.finalize(&self.location_flags)?; + let load_key = self.next_load_key.borrow_mut().take(); + + match (result, load_key) { + (result @ None, None) => *result = Some(PresetFile::Single(preset)), + (result @ None, Some(load_key)) => { + let mut presets = BTreeMap::new(); + presets.insert(load_key, preset); + *result = Some(PresetFile::Container(presets)); } + (Some(PresetFile::Container(presets)), Some(load_key)) => { + presets.insert(load_key, preset); + } + _ => unreachable!(), } + + Ok(()) } unsafe extern "C" fn on_error( @@ -388,24 +352,20 @@ impl<'a> MetadataReceiver<'a> { os_error: i32, error_message: *const c_char, ) { - // We'll have a dedicated error message for a missing `error_message` - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::on_error", + |this| -> Result<()> { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::on_error()"); + let error_message = + unsafe { util::cstr_ptr_to_mandatory_string(error_message) }.context("Error message is invalid")?; - let error_message = unsafe { util::cstr_ptr_to_mandatory_string(error_message) }.context( - "'clap_preset_discovery_metadata_receiver::on_error()' called with an invalid error \ - message", + anyhow::bail!( + "Load error occurred: OS error code {os_error} with the following error message: {error_message}" + ); + }, ); - match error_message { - Ok(error_message) => this.set_callback_error(format!( - "'clap_preset_discovery_metadata_receiver::on_error()' called for OS error code \ - {os_error} with the following error message: {error_message}" - )), - // This would be quite ironic - Err(err) => this.set_callback_error(format!("{err:#}")), - } } unsafe extern "C" fn begin_preset( @@ -413,80 +373,47 @@ impl<'a> MetadataReceiver<'a> { name: *const c_char, load_key: *const c_char, ) -> bool { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); - - this.assert_same_thread("clap_preset_discovery_metadata_receiver::begin_preset()"); - - let name = unsafe { util::cstr_ptr_to_optional_string(name) }.context( - "'clap_preset_discovery_metadata_receiver::begin_preset()' called with an invalid \ - name parameter", - ); - let load_key = unsafe { util::cstr_ptr_to_optional_string(load_key) }.context( - "'clap_preset_discovery_metadata_receiver::begin_preset()' called with an invalid \ - load_key parameter", - ); - match (name, load_key) { - (Ok(name), Ok(load_key)) => { - // We'll check for some errorous situations first. The `result` borrow needs to be - // dropped before calling `maybe_write_preset()` as it will try to borrow it mutably - { - let result = this.result.borrow(); - let error_message = match (&*result, &load_key) { - // If there was an error then just immediately exit since nothing will change that - (Some(Err(_)), _) => return false, - (Some(Ok(PresetFile::Single(_))), None) => Some( - "calling 'begin_preset()' a second time for a non-container preset \ - file with no load key is not allowed.", - ), - (Some(Ok(PresetFile::Single(_))), Some(_)) => Some( - "'begin_preset()' was called without a load key for the first time, \ - and with a load key the second time. This is invalid behavior.", - ), - (Some(Ok(PresetFile::Container(_))), None) => Some( - "'begin_preset()' was called with a load key for the first time, and \ - without a load key the second time. This is invalid behavior.", - ), - // If this is the first call and there are no errors then everything's fine - (None, _) | (Some(Ok(PresetFile::Container(_))), Some(_)) => None, - }; - - if let Some(error_message) = error_message { - this.set_callback_error(format!( - "Error in 'clap_preset_discovery_metadata_receiver::begin_preset()' \ - call: {error_message}" - )); - return false; - } + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::begin_preset", + |this| { + this.assert_same_thread()?; + + let name = unsafe { util::cstr_ptr_to_optional_string(name) }.context("Name argument is invalid")?; + let load_key = + unsafe { util::cstr_ptr_to_optional_string(load_key) }.context("Load key argument is invalid")?; + + let result = this.result.lock().unwrap(); + match (&*result, &load_key) { + (Err(_), _) => return Ok(false), + (Ok(Some(PresetFile::Single(_))), _) => anyhow::bail!( + "Calling 'begin_preset()' a second time for a non-container (no load key) preset file is not \ + allowed" + ), + (Ok(Some(PresetFile::Container(_))), None) => anyhow::bail!( + "'begin_preset()' was called with a load key for the first time, and without a load key the \ + second time. This is invalid behavior" + ), + _ => {} } // Container presets have a load key, single-preset files don't have a load key. The // name field is mandatory for container presets, and optional for non-container // presets. If it's not specified we'll use the file name instead. let preset_name = match (name, &load_key) { - (None, None) => PresetName::Filename(match this.location.file_name() { - Ok(file_name) => file_name, - Err(err) => { - this.set_callback_error(format!( - "Could not derive a file name from {}: {:#}", - this.location, err - )); - return false; - } - }), (Some(name), _) => PresetName::Explicit(name), - (None, Some(_)) => { - this.set_callback_error( - "Container presets must specify a preset name.".to_string(), - ); - return false; - } + (None, Some(_)) => anyhow::bail!("Container presets must specify a preset name"), + (None, None) => PresetName::Filename( + this.location + .file_name() + .with_context(|| format!("Could not derive a file name from {}", this.location))?, + ), }; // If this is a subsequent `begin_preset()` call for a container preset, then the // old preset is written to `self.result` before starting a new one. if load_key.is_some() { - this.maybe_write_preset(); + this.flush_preset()?; } // This starts the declaration of a new preset. The methods below this write to this @@ -495,45 +422,31 @@ impl<'a> MetadataReceiver<'a> { *this.next_load_key.borrow_mut() = load_key; *this.next_preset_data.borrow_mut() = Some(PartialPreset::new(preset_name)); - true - } - (Err(err), _) | (_, Err(err)) => { - this.set_callback_error(format!("{err:#}")); - - false - } - } + Ok(true) + }, + ) + .unwrap_or(false) } unsafe extern "C" fn add_plugin_id( receiver: *const clap_preset_discovery_metadata_receiver, - plugin_id: *const clap_plugin_id, + plugin_id: *const clap_universal_plugin_id, ) { - check_null_ptr!(receiver, (*receiver).receiver_data, plugin_id); - let this = &*((*receiver).receiver_data as *const Self); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::add_plugin_id", + |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::add_plugin_id()"); + let abi = unsafe { util::cstr_ptr_to_mandatory_string((*plugin_id).abi) } + .context("'plugin_id.abi' is invalid")?; + let id = unsafe { util::cstr_ptr_to_mandatory_string((*plugin_id).id) } + .context("'plugin_id.id' is invalid")?; - let abi = unsafe { util::cstr_ptr_to_mandatory_string((*plugin_id).abi) }.context( - "'clap_preset_discovery_metadata_receiver::add_plugin_id()' called with an invalid \ - abi field", - ); - let id = unsafe { util::cstr_ptr_to_mandatory_string((*plugin_id).id) }.context( - "'clap_preset_discovery_metadata_receiver::add_plugin_id()' called with an invalid id \ - field", - ); - match (abi, id) { - (Ok(abi), Ok(id)) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::add_plugin_id()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; if abi == "clap" { @@ -543,148 +456,112 @@ impl<'a> MetadataReceiver<'a> { }); } else if abi.trim().eq_ignore_ascii_case("clap") { // Let's just assume noone comes up with a painfully sarcastic 'ClAp' standard - this.set_callback_error(format!( - "'{abi}' was provided as an ABI argument to \ - 'clap_preset_discovery_metadata_receiver::add_plugin_id()'. This is \ - probably a typo. The expected value is 'clap' in all lowercase." - )); + anyhow::bail!( + "'{abi}' was provided as an ABI argument. This is probably a typo. The expected value is \ + 'clap' in all lowercase." + ); } else { next_preset_data.plugin_ids.push(PluginId { abi: PluginAbi::Other(abi), id, }); } - } - (Err(err), _) | (_, Err(err)) => this.set_callback_error(format!("{err:#}")), - } + + Ok(()) + }, + ); } unsafe extern "C" fn set_soundpack_id( receiver: *const clap_preset_discovery_metadata_receiver, soundpack_id: *const c_char, ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::set_soundpack_id", + |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::set_soundpack_id()"); + let soundpack_id = + unsafe { util::cstr_ptr_to_mandatory_string(soundpack_id) }.context("Soundpack ID is invalid")?; - let soundpack_id = unsafe { util::cstr_ptr_to_mandatory_string(soundpack_id) }.context( - "'clap_preset_discovery_metadata_receiver::set_soundpack_id()' called with an invalid \ - parameter", - ); - match soundpack_id { - Ok(soundpack_id) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_soundpack_id()' with \ - no preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.soundpack_id = Some(soundpack_id); - } - Err(err) => this.set_callback_error(format!("{err:#}")), - } + Ok(()) + }, + ); } - unsafe extern "C" fn set_flags( - receiver: *const clap_preset_discovery_metadata_receiver, - flags: u32, - ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); - - this.assert_same_thread("clap_preset_discovery_metadata_receiver::set_flags()"); - - let mut next_preset_data = this.next_preset_data.borrow_mut(); - let next_preset_data = match &mut *next_preset_data { - Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_flags()' with no preceding \ - 'begin_preset()' call. This is not valid.", - ); - return; - } - }; + unsafe extern "C" fn set_flags(receiver: *const clap_preset_discovery_metadata_receiver, flags: u32) { + Self::wrap(receiver, "clap_preset_discovery_metadata_receiver::set_flags", |this| { + this.assert_same_thread()?; + + let mut next_preset_data = this.next_preset_data.borrow_mut(); + let next_preset_data = match &mut *next_preset_data { + Some(next_preset_data) => next_preset_data, + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), + }; + + next_preset_data.flags = Some(Flags { + is_factory_content: (flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) != 0, + is_user_content: (flags & CLAP_PRESET_DISCOVERY_IS_USER_CONTENT) != 0, + is_demo_content: (flags & CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT) != 0, + is_favorite: (flags & CLAP_PRESET_DISCOVERY_IS_FAVORITE) != 0, + }); - next_preset_data.flags = Some(Flags { - is_factory_content: (flags & CLAP_PRESET_DISCOVERY_IS_FACTORY_CONTENT) != 0, - is_user_content: (flags & CLAP_PRESET_DISCOVERY_IS_USER_CONTENT) != 0, - is_demo_content: (flags & CLAP_PRESET_DISCOVERY_IS_DEMO_CONTENT) != 0, - is_favorite: (flags & CLAP_PRESET_DISCOVERY_IS_FAVORITE) != 0, + Ok(()) }); } - unsafe extern "C" fn add_creator( - receiver: *const clap_preset_discovery_metadata_receiver, - creator: *const c_char, - ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + unsafe extern "C" fn add_creator(receiver: *const clap_preset_discovery_metadata_receiver, creator: *const c_char) { + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::add_creator", + |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::set_creator()"); + let creator = unsafe { util::cstr_ptr_to_mandatory_string(creator) }.context("Creator is invalid")?; - let creator = unsafe { util::cstr_ptr_to_mandatory_string(creator) }.context( - "'clap_preset_discovery_metadata_receiver::set_creator()' called with an invalid \ - parameter", - ); - match creator { - Ok(creator) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_creator()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.creators.push(creator); - } - Err(err) => this.set_callback_error(format!("{err:#}")), - } + Ok(()) + }, + ); } unsafe extern "C" fn set_description( receiver: *const clap_preset_discovery_metadata_receiver, description: *const c_char, ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::set_description", + |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::set_description()"); + let description = + unsafe { util::cstr_ptr_to_mandatory_string(description) }.context("Description is invalid")?; - let description = unsafe { util::cstr_ptr_to_mandatory_string(description) }.context( - "'clap_preset_discovery_metadata_receiver::set_description()' called with an invalid \ - parameter", - ); - match description { - Ok(description) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_description()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.description = Some(description); - } - Err(err) => this.set_callback_error(format!("{err:#}")), - } + Ok(()) + }, + ); } unsafe extern "C" fn set_timestamps( @@ -692,77 +569,53 @@ impl<'a> MetadataReceiver<'a> { creation_time: clap_timestamp, modification_time: clap_timestamp, ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); - - this.assert_same_thread("clap_preset_discovery_metadata_receiver::set_timestamps()"); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::set_timestamps", + |this| { + this.assert_same_thread()?; + + // These are parsed to `None` values if the timestamp is 0/CLAP_TIMESTAMP_UNKNOWN + let creation_time = parse_timestamp(creation_time).context("Creation time is invalid")?; + let modification_time = parse_timestamp(modification_time).context("Modification time is invalid")?; + + anyhow::ensure!( + creation_time.is_some() || modification_time.is_some(), + "Both arguments are set to 'CLAP_TIMESTAMP_UNKNOWN'" + ); - // These are parsed to `None` values if the timestamp is 0/CLAP_TIMESTAMP_UNKNOWN - let creation_time = util::parse_timestamp(creation_time).context( - "'clap_preset_discovery_metadata_receiver::set_timestamps()' called with an invalid \ - creation_time parameter", - ); - let modification_time = util::parse_timestamp(modification_time).context( - "'clap_preset_discovery_metadata_receiver::set_timestamps()' called with an invalid \ - modification_time parameter", - ); - match (creation_time, modification_time) { - // Calling the function like htis doesn't make any sense, so we'll point that out - (Ok(None), Ok(None)) => this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_timestamps()' called with both \ - arguments set to 'CLAP_TIMESTAMP_UNKNOWN'.", - ), - (Ok(creation_time), Ok(modification_time)) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::set_timestamps()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.creation_time = creation_time; next_preset_data.modification_time = modification_time; - } - (Err(err), _) | (_, Err(err)) => this.set_callback_error(format!("{err:#}")), - } - } - unsafe extern "C" fn add_feature( - receiver: *const clap_preset_discovery_metadata_receiver, - feature: *const c_char, - ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + Ok(()) + }, + ); + } - this.assert_same_thread("clap_preset_discovery_metadata_receiver::add_feature()"); + unsafe extern "C" fn add_feature(receiver: *const clap_preset_discovery_metadata_receiver, feature: *const c_char) { + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::add_feature", + |this| { + this.assert_same_thread()?; + let feature = unsafe { util::cstr_ptr_to_mandatory_string(feature) }.context("Feature is invalid")?; - let feature = unsafe { util::cstr_ptr_to_mandatory_string(feature) }.context( - "'clap_preset_discovery_metadata_receiver::add_feature()' called with an invalid \ - parameter", - ); - match feature { - Ok(feature) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::add_plugin_id()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.features.push(feature); - } - Err(err) => this.set_callback_error(format!("{err:#}")), - } + Ok(()) + }, + ); } unsafe extern "C" fn add_extra_info( @@ -770,36 +623,24 @@ impl<'a> MetadataReceiver<'a> { key: *const c_char, value: *const c_char, ) { - check_null_ptr!(receiver, (*receiver).receiver_data); - let this = &*((*receiver).receiver_data as *const Self); + Self::wrap( + receiver, + "clap_preset_discovery_metadata_receiver::add_extra_info", + |this| { + this.assert_same_thread()?; - this.assert_same_thread("clap_preset_discovery_metadata_receiver::add_extra_info()"); + let key = unsafe { util::cstr_ptr_to_mandatory_string(key) }.context("Key is invalid")?; + let value = unsafe { util::cstr_ptr_to_mandatory_string(value) }.context("Value is invalid")?; - let key = unsafe { util::cstr_ptr_to_mandatory_string(key) }.context( - "'clap_preset_discovery_metadata_receiver::add_extra_info()' called with an invalid \ - key parameter", - ); - let value = unsafe { util::cstr_ptr_to_mandatory_string(value) }.context( - "'clap_preset_discovery_metadata_receiver::add_extra_info()' called with an invalid \ - value parameter", - ); - match (key, value) { - (Ok(key), Ok(value)) => { let mut next_preset_data = this.next_preset_data.borrow_mut(); let next_preset_data = match &mut *next_preset_data { Some(next_preset_data) => next_preset_data, - None => { - this.set_callback_error( - "'clap_preset_discovery_metadata_receiver::add_extra_info()' with no \ - preceding 'begin_preset()' call. This is not valid.", - ); - return; - } + None => anyhow::bail!("No preceding 'begin_preset()' call. This is not valid."), }; next_preset_data.extra_info.insert(key, value); - } - (Err(err), _) | (_, Err(err)) => this.set_callback_error(format!("{err:#}")), - } + Ok(()) + }, + ); } } diff --git a/src/plugin/preset_discovery/provider.rs b/src/plugin/preset_discovery/provider.rs index 4106f86..fd02d23 100644 --- a/src/plugin/preset_discovery/provider.rs +++ b/src/plugin/preset_discovery/provider.rs @@ -1,20 +1,18 @@ //! A wrapper around `clap_preset_discovery_provider`. +use super::indexer::{Indexer, IndexerResults}; +use super::metadata_receiver::{MetadataReceiver, PresetFile}; +use super::{Location, LocationValue, PresetDiscoveryFactory, ProviderMetadata}; +use crate::cli::tracing::{Span, record}; +use crate::plugin::util::{Proxy, clap_call}; use anyhow::{Context, Result}; +use clap_sys::factory::preset_discovery::clap_preset_discovery_provider; use std::collections::{BTreeMap, HashSet}; use std::ffi::CString; use std::marker::PhantomData; -use std::pin::Pin; use std::ptr::NonNull; use walkdir::WalkDir; -use clap_sys::factory::draft::preset_discovery::clap_preset_discovery_provider; - -use super::indexer::{Indexer, IndexerResults}; -use super::metadata_receiver::{MetadataReceiver, PresetFile}; -use super::{Location, LocationValue, PresetDiscoveryFactory, ProviderMetadata}; -use crate::util::unsafe_clap_call; - /// A preset discovery provider created from a preset discovery factory. The provider is initialized /// and the declared contents are read when the object is created, and the provider is destroyed /// when this object is dropped. @@ -33,7 +31,7 @@ pub struct Provider<'a> { /// /// Since there are currently no extensions the plugin shouldn't be interacting with it anymore /// after the `init()` call, but it still needs outlive the provider. - _indexer: Pin>, + _indexer: Proxy, /// The factory this provider was created form. Only used for the lifetime. _factory: &'a PresetDiscoveryFactory<'a>, /// To honor CLAP's thread safety guidelines, this provider cannot be shared with or sent to @@ -47,41 +45,57 @@ impl<'a> Provider<'a> { pub fn new(factory: &'a PresetDiscoveryFactory, provider_id: &str) -> Result { let indexer = Indexer::new(); - let provider_id_cstring = - CString::new(provider_id).expect("The provider ID contained internal null bytes"); + let provider_id_cstring = CString::new(provider_id).expect("The provider ID contained internal null bytes"); let provider = { + let span = Span::begin( + "clap_preset_discovery_factory::create", + record! { + provider_id: provider_id + }, + ); + let factory = factory.as_ptr(); - let provider = unsafe_clap_call! { - factory=>create( - factory, - indexer.clap_preset_discovery_indexer_ptr(), - provider_id_cstring.as_ptr() - ) + let provider = unsafe { + clap_call! { + factory=>create( + factory, + Proxy::vtable(&indexer), + provider_id_cstring.as_ptr() + ) + } }; + + span.finish(record!(result: format_args!("{:p}", provider))); + match NonNull::new(provider as *mut clap_preset_discovery_provider) { Some(provider) => provider, None => anyhow::bail!( - "'clap_preset_discovery_factory::create()' returned a null pointer for the \ - provider with ID '{provider_id}'.", + "'clap_preset_discovery_factory::create()' returned a null pointer for the provider with ID \ + '{provider_id}'.", ), } }; let declared_data = { let provider = provider.as_ptr(); - if !unsafe_clap_call! { provider=>init(provider) } { + + let span = Span::begin("clap_preset_discovery_provider::init", ()); + let result = unsafe { + clap_call! { provider=>init(provider) } + }; + + span.finish(record!(result: result)); + + if !result { anyhow::bail!( - "'clap_preset_discovery_factory::init()' returned false for the provider with \ - ID '{provider_id}'." + "'clap_preset_discovery_provider::init()' returned false for the provider with ID '{provider_id}'." ); } - // TODO: After this point the provider should not declare any more data. We don't - // currently test for this. - indexer.results().with_context(|| { + indexer.finish().with_context(|| { format!( - "Errors produced during 'clap_preset_discovery_indexer' callbacks made by the \ - provider with ID '{provider_id}'" + "Errors produced during 'clap_preset_discovery_indexer' callbacks made by the provider with ID \ + '{provider_id}'" ) })? }; @@ -102,13 +116,12 @@ impl<'a> Provider<'a> { pub fn descriptor(&self) -> Result { let provider = self.as_ptr(); let descriptor = unsafe { (*provider).desc }; + if descriptor.is_null() { - anyhow::bail!( - "The 'desc' field on the 'clap_preset_provider' struct is a null pointer." - ); + anyhow::bail!("The 'desc' field on the 'clap_preset_provider' struct is a null pointer."); } - ProviderMetadata::from_descriptor(unsafe { &*descriptor }) + unsafe { ProviderMetadata::from_descriptor(descriptor) } } /// Get the raw pointer to the `clap_preset_discovery_provider` instance. @@ -127,64 +140,58 @@ impl<'a> Provider<'a> { /// plugin triggered any kind of error. The returned map contains a [`PresetFile`] for each of /// the crawled locations that the plugin declared presets for, which can be either a single /// preset or a container of multiple presets. - pub fn crawl_location( - &self, - location: &Location, - ) -> Result> { + pub fn crawl_location(&self, location: &Location) -> Result> { let mut results = BTreeMap::new(); let location_flags = location.flags; let mut crawl = |location: LocationValue| -> Result<()> { let (location_kind, location_ptr) = location.to_raw(); - // There is no 'end of preset' kind of function in the metadata provider, so when - // the `MetadataReceiver` is dropped it may still need to write a preset file or - // emit some errors. That's why it borrows this result, and writes the output - // theere. This can happen during the drop. - let mut result = None; - { - let metadata_receiver = - MetadataReceiver::new(&mut result, &location, location_flags); - - let provider = self.as_ptr(); - let success = unsafe_clap_call! { + let metadata_receiver = MetadataReceiver::new(location.clone(), location_flags); + let provider = self.as_ptr(); + + let span = Span::begin( + "clap_preset_discovery_provider::get_metadata", + record! { + location: location, + location_flags: location_flags + }, + ); + + let result = unsafe { + clap_call! { provider=>get_metadata( provider, location_kind, location_ptr, - metadata_receiver.clap_preset_discovery_metadata_receiver_ptr() + Proxy::vtable(&metadata_receiver) ) - }; - if !success { - // TODO: Is the plugin allowed to return false here? If it doesn't have any - // presets it should just not declare any, right? - anyhow::bail!( - "The preset provider returned false when fetching metadata for {location}.", - ); } + }; + + span.finish(record!(result: result)); + + if !result { + anyhow::bail!("The preset provider returned false when fetching metadata for {location}.",); } - if let Some(preset_file) = result { - let preset_file = preset_file.with_context(|| { - format!("Error while fetching fetching metadata for {location}") - })?; + let result = metadata_receiver + .finish() + .with_context(|| format!("Error while fetching fetching metadata for {location}"))?; + if let Some(preset_file) = result { results.insert(location, preset_file); } Ok(()) }; - match &location.value { - LocationValue::File(file_path) => { + match location.value.file_path() { + Some(file_path) => { // Single files are queried as is, directories are crawled. If the declared location // does not exist, then that results in a hard error. - let file_path_str = file_path - .to_str() - .context("Invalid UTF-8 in location path")?; - let metadata = std::fs::metadata(file_path_str).with_context(|| { - "Could not query metadata for the declared file location '{file_path_str}'" - })?; + let metadata = std::fs::metadata(&file_path) + .with_context(|| "Could not query metadata for the declared file location '{file_path}'")?; if metadata.is_dir() { // If the plugin declared valid file extensions, then we'll filter by those file // extensions @@ -195,7 +202,7 @@ impl<'a> Provider<'a> { .map(|file_type| file_type.extension.as_str()) .collect(); - let walker = WalkDir::new(file_path_str) + let walker = WalkDir::new(file_path) .min_depth(1) .follow_links(true) .same_file_system(false) @@ -215,20 +222,15 @@ impl<'a> Provider<'a> { // directories. If the plugin doesn't return an error but also doesn't // declare any presets then that gets handled gracefully crawl(LocationValue::File( - CString::new( - candidate - .path() - .to_str() - .context("Invalid UTF-8 in file path")?, - ) - .expect("File path contained null bytes"), + CString::new(candidate.path().to_string_lossy().to_string()) + .context("File path contains nul byte")?, ))?; } } else { crawl(location.value.clone())?; } } - LocationValue::Internal => { + None => { crawl(LocationValue::Internal)?; } } diff --git a/src/plugin/process.rs b/src/plugin/process.rs new file mode 100644 index 0000000..4ca309b --- /dev/null +++ b/src/plugin/process.rs @@ -0,0 +1,388 @@ +//! Data structures and functions surrounding audio processing. +use crate::plugin::instance::{PluginAudioThread, PluginStatus, ProcessInfo, ProcessStatus}; +use crate::plugin::util::Proxy; +use anyhow::Result; +use either::Either; + +mod buffer; +mod events; +mod transport; + +pub use buffer::*; +pub use events::*; +pub use transport::*; + +pub struct ProcessScope<'a> { + plugin: &'a PluginAudioThread<'a>, + buffer: &'a mut AudioBuffers, + + events_input: Proxy, + events_output: Proxy, + + transport: TransportState, + sample_rate: f64, + min_buffer_size: u32, + + check_denormals: bool, + check_outputs: Vec, +} + +impl<'a> ProcessScope<'a> { + pub fn new(plugin: &'a PluginAudioThread, buffer: &'a mut AudioBuffers) -> Result { + Self::with_config(plugin, buffer, 44100.0, 1) + } + + pub fn with_config( + plugin: &'a PluginAudioThread, + buffer: &'a mut AudioBuffers, + sample_rate: f64, + min_buffer_size: u32, + ) -> Result { + plugin.status().assert_is(PluginStatus::Deactivated); + + Ok(ProcessScope { + check_denormals: true, + check_outputs: vec![true; buffer.num_outputs()], + + plugin, + buffer, + events_input: InputEventQueue::new(), + events_output: OutputEventQueue::new(), + transport: TransportState::dummy(), + sample_rate, + min_buffer_size, + }) + } + + pub fn set_allow_denormals(&mut self, allow: bool) { + self.check_denormals = !allow; + } + + pub fn set_output_active(&mut self, index: u32, active: bool) { + if let Some(mask) = self.check_outputs.get_mut(index as usize) { + *mask = active; + } + } + + pub fn sample_rate(&self) -> f64 { + self.sample_rate + } + + pub fn max_block_size(&self) -> u32 { + self.buffer.samples() + } + + pub fn wants_restart(&self) -> bool { + self.plugin.shared().requested_restart.load() + } + + pub fn add_events(&mut self, events: impl IntoIterator) { + self.events_input.add_events(events); + } + + #[allow(unused)] + pub fn read_events(&self) -> Vec { + self.events_output.read() + } + + pub fn transport(&mut self) -> &mut TransportState { + &mut self.transport + } + + pub fn audio_buffers(&mut self) -> &mut AudioBuffers { + self.buffer + } + + pub fn reset(&mut self) { + if self.plugin.status() >= PluginStatus::Activated { + self.plugin.reset(); + } + } + + pub fn run(&mut self) -> Result { + self.run_with(self.buffer.samples()) + } + + pub fn run_with(&mut self, block_size: u32) -> Result { + assert!(block_size > 0 && block_size <= self.buffer.samples()); + + self.activate()?; + + // check that we dont overfill the input event queue + assert!( + self.events_input.last_event_time().is_none_or(|t| t < block_size), + "The input event queue contains events beyond the current processing block size" + ); + + // prepare output event queue for processing + self.events_output.clear(); + + // prepare output audio buffers for processing + // this is used to detect uninitialized output buffers + for buffer in self.buffer.iter_mut() { + if buffer.port().input().is_none() { + buffer.fill(CHECK_NAN_F32, CHECK_NAN_F64); + } + } + + // save original buffers for consistency check + let original_buffers = self.buffer[..].to_owned(); + + // run processing + let status = self.buffer.process(|inputs, outputs| { + let transport = self.transport.as_clap_transport(0); + self.plugin.process(ProcessInfo { + frames_count: block_size, + steady_time: self.transport.sample_pos, + audio_inputs: inputs, + audio_outputs: outputs, + input_events: &self.events_input, + output_events: &self.events_output, + transport: (!self.transport.is_freerun).then_some(&transport), + }) + })?; + + // clear input event queue and advance transport + self.events_input.clear(); + self.transport.advance(block_size as i64, self.sample_rate()); + + // check output audio buffers for NaNs or infinities + check_process_call_consistency( + &self.buffer[..], + &original_buffers, + &self.events_output.read(), + block_size, + self.check_denormals, + &self.check_outputs, + )?; + + Ok(status) + } + + /// Activate/start processing if needed. + /// + /// The state will be [`PluginStatus::Processing`] if successful. + pub fn activate(&mut self) -> Result<()> { + if self.plugin.shared().requested_restart.load() { + log::debug!("Plugin has requested a restart"); + self.deactivate(); + } + + // check state, activate if needed + if self.plugin.status() == PluginStatus::Deactivated { + self.plugin.shared().requested_restart.store(false); + + let min_buffer_size = self.min_buffer_size; + let sample_rate = self.sample_rate; + let buffer_size = self.buffer.samples(); + + self.plugin + .on_main_thread(move |plugin| plugin.activate(sample_rate, min_buffer_size, buffer_size))?; + } + + // start processing if needed + if self.plugin.status() == PluginStatus::Activated { + self.plugin.start_processing()?; + } + + Ok(()) + } + + /// Deactivate/stop processing if needed. + /// + /// The state will be [`PluginStatus::Deactivated`] if successful. + pub fn deactivate(&mut self) { + self.plugin.shared().requested_restart.store(false); + + if self.plugin.status() == PluginStatus::Processing { + self.plugin.stop_processing(); + } + + if self.plugin.status() == PluginStatus::Activated { + self.plugin.on_main_thread(|plugin| plugin.deactivate()); + } + } +} + +impl Drop for ProcessScope<'_> { + fn drop(&mut self) { + self.deactivate(); + } +} + +/// NaN values used for checking if output buffers have been written to. +/// These are quiet NaNs with a specific payload to avoid accidental matches with other NaN values. +/// The payload is chosen to be unlikely to appear in normal processing. +const CHECK_NAN_F32: f32 = f32::from_bits(0x7FC0_1234); +/// See [`CHECK_NAN_F32`]. +const CHECK_NAN_F64: f64 = f64::from_bits(0x7FF8_1234_5678_1234); + +/// The process for consistency. This verifies that the output buffer has been written to, doesn't contain any NaN, +/// infinite, or denormal values, that the input buffers have not been modified by the plugin, and +/// that the output event queue is monotonically ordered. +fn check_process_call_consistency( + resulting_buffers: &[AudioBuffer], + original_buffers: &[AudioBuffer], + output_events: &[Event], + block_size: u32, + check_denormals: bool, + check_outputs: &[bool], +) -> Result<()> { + for (buffer, before) in resulting_buffers.iter().zip(original_buffers.iter()) { + // Input-only buffers must not be overwritten during out of place processing + match buffer.port() { + AudioBufferPort::Input(index) => { + // find a mismatching sample + for channel in 0..buffer.channels() { + for sample in 0..buffer.samples() { + let x = buffer.get(channel, sample); + let y = before.get(channel, sample); + + anyhow::ensure!( + x == y, + "The plugin has overwritten an input buffer (index {index}) during out-of-place \ + processing, at channel {channel} and sample index {sample}." + ); + } + } + } + + // Output buffers must not contain any non-finite or denormal values + AudioBufferPort::Output(port_idx) | AudioBufferPort::Inplace(_, port_idx) => { + if !check_outputs.get(port_idx).copied().unwrap_or(false) { + continue; + } + + // check output constant masks + for channel in 0..buffer.channels() { + if buffer.get_output_constant_mask().is_channel_constant(channel) + && let Err(e) = check_channel_quiet(buffer.channel(channel), true) + { + anyhow::bail!( + "The output channel {channel} of port {port_idx} is not constant despite the constant \ + flag being set ({e:.2} dBFS)." + ); + } + } + + // check for invalid samples (unwritten, NaN, infinite, or denormal) + let invalid_sample = (0..buffer.channels()) + .flat_map(|channel| (0..block_size).map(move |sample| (channel, sample))) + .find_map(|(channel, sample)| { + let x = buffer.get(channel, sample); + if x.either( + |x| !x.is_finite() || (x.is_subnormal() && check_denormals), + |x| !x.is_finite() || (x.is_subnormal() && check_denormals), + ) { + Some((x, channel, sample)) + } else { + None + } + }); + + if let Some((sample, channel_idx, sample_idx)) = invalid_sample { + let is_subnormal = sample.either(|x| x.is_subnormal(), |x| x.is_subnormal()); + let is_unwritten = sample.either( + |x| x.to_bits() == CHECK_NAN_F32.to_bits(), + |x| x.to_bits() == CHECK_NAN_F64.to_bits(), + ); + + if is_subnormal { + anyhow::bail!( + "The sample written to output port {port_idx}, channel {channel_idx}, and sample index \ + {sample_idx} is subnormal ({sample})." + ); + } else if is_unwritten { + anyhow::bail!( + "The sample at output port {port_idx}, channel {channel_idx}, and sample index \ + {sample_idx} was left unwritten." + ); + } else { + anyhow::bail!( + "The sample written to output port {port_idx}, channel {channel_idx}, and sample index \ + {sample_idx} is {sample}." + ); + } + } + + // check for out-of-bounds overwritten samples + let overwritten_sample = (0..buffer.channels()) + .flat_map(|channel| (block_size..buffer.samples()).map(move |sample| (channel, sample))) + .find_map(|(channel, sample)| { + let bitwise_match = match (buffer.get(channel, sample), before.get(channel, sample)) { + (Either::Left(x), Either::Left(y)) => x.to_bits() == y.to_bits(), + (Either::Right(x), Either::Right(y)) => x.to_bits() == y.to_bits(), + _ => false, + }; + + if !bitwise_match { Some((channel, sample)) } else { None } + }); + + if let Some((channel_idx, sample_idx)) = overwritten_sample { + anyhow::bail!( + "The plugin has overwritten a sample beyond the current processing block size at channel \ + {channel_idx} and sample index {sample_idx}. The block size is {block_size}." + ); + } + } + } + } + + // If the plugin output any events, then they should be in a monotonically increasing order + let mut last_event_time = 0; + for event in output_events { + let event_time = event.header().time; + if event_time < last_event_time { + anyhow::bail!( + "The plugin output an event for sample {event_time} after it had previously output an event for \ + sample {last_event_time}." + ) + } + + if event_time >= block_size { + anyhow::bail!( + "The plugin output an event for sample {} but the audio buffer only contains {} samples.", + event_time, + block_size + ) + } + + if matches!(event, Event::Transport(_)) { + anyhow::bail!("The plugin emitted a transport event during processing, which is not allowed."); + } + + last_event_time = event_time; + } + + Ok(()) +} + +/// A channel is considered quiet if the signal is below -60 dbfs, ignoring DC. +/// +/// This function is designed to be very lenient in what it considers "quiet", to avoid false positives. +/// Returns `Ok(())` if the channel is quiet, or `Err(max_amplitude_in_db)` if not. +pub fn check_channel_quiet(channel: Either<&[f32], &[f64]>, ignore_dc: bool) -> Result<(), f64> { + /// -60 dbfs + const QUIET_THRESHOLD: f64 = 0.001; + + let (min, max) = match channel { + Either::Right(x) => x.iter().fold((f64::MAX, f64::MIN), |(min, max), &sample| { + (min.min(sample.abs()), max.max(sample.abs())) + }), + Either::Left(x) => { + let (min, max) = x.iter().fold((f32::MAX, f32::MIN), |(min, max), &sample| { + (min.min(sample.abs()), max.max(sample.abs())) + }); + + (min as f64, max as f64) + } + }; + + let range = if ignore_dc { (max - min) * 0.5 } else { max.max(-min) }; + + if range < QUIET_THRESHOLD { + Ok(()) + } else { + Err(20.0 * range.log10()) + } +} diff --git a/src/plugin/process/buffer.rs b/src/plugin/process/buffer.rs new file mode 100644 index 0000000..954a504 --- /dev/null +++ b/src/plugin/process/buffer.rs @@ -0,0 +1,499 @@ +use crate::plugin::ext::audio_ports::{AudioPort, AudioPortConfig}; +use crate::plugin::process::ConstantMask; +use anyhow::Result; +use clap_sys::audio_buffer::*; +use either::Either; +use rand::{Rng, RngExt}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::mem::zeroed; +use std::ops::{Deref, DerefMut}; +use std::ptr::null_mut; + +/// Audio buffers for audio processing. These contain both input and output buffers, that can be either in-place +/// or out-of-place, single or double precision. +#[derive(Clone)] +pub struct AudioBuffers { + /// These are all indexed by `[port_idx][channel_idx][sample_idx]`. The inputs also need to be + /// mutable because reborrwing them from here is the only way to modify them without + /// reinitializing the pointers. + buffers: Box<[AudioBuffer]>, + + /// The CLAP audio buffer representations for inputs + clap_inputs: Box<[clap_audio_buffer]>, + /// The CLAP audio buffer representations for outputs + clap_outputs: Box<[clap_audio_buffer]>, + + ptrs_inputs: Box<[Box<[*mut ()]>]>, + ptrs_outputs: Box<[Box<[*mut ()]>]>, + + /// The number of samples for this buffer. This is consistent across all inner vectors. + samples: u32, +} + +#[derive(Debug, Clone)] +pub struct AudioBuffer { + port: AudioBufferPort, + + input_constant_mask: ConstantMask, + output_constant_mask: ConstantMask, + + input_latency: u32, + output_latency: u32, + + #[allow(clippy::type_complexity)] + data: Either]>, Box<[Box<[f64]>]>>, + samples: u32, +} + +/// A port to which an audio buffer belongs. +#[derive(Clone, Copy, Debug)] +pub enum AudioBufferPort { + Input(usize), + Output(usize), + Inplace(usize, usize), +} + +impl AudioBuffers { + /// Construct the audio buffers from the given buffer configurations. The number of samples must + /// be greater than zero and all channel vectors must have the same length. + pub fn new(buffers: Vec, samples: u32) -> Self { + let mut clap_inputs: Vec = vec![]; + let mut clap_outputs: Vec = vec![]; + let mut ptrs_inputs: Vec> = vec![]; + let mut ptrs_outputs: Vec> = vec![]; + + for buffer in buffers.iter() { + assert!( + buffer.samples() == samples, + "All audio buffers must have the same number of samples." + ); + + if let Some(input) = buffer.port().input() { + if clap_inputs.len() <= input { + clap_inputs.resize(input + 1, unsafe { zeroed() }); + ptrs_inputs.resize(input + 1, Box::new([])); + } + + ptrs_inputs[input] = vec![null_mut(); buffer.channels() as usize].into_boxed_slice(); + } + + if let Some(output) = buffer.port().output() { + if clap_outputs.len() <= output { + clap_outputs.resize(output + 1, unsafe { zeroed() }); + ptrs_outputs.resize(output + 1, Box::new([])); + } + + ptrs_outputs[output] = vec![null_mut(); buffer.channels() as usize].into_boxed_slice(); + } + } + + Self { + clap_inputs: clap_inputs.into_boxed_slice(), + clap_outputs: clap_outputs.into_boxed_slice(), + ptrs_inputs: ptrs_inputs.into_boxed_slice(), + ptrs_outputs: ptrs_outputs.into_boxed_slice(), + buffers: buffers.into_boxed_slice(), + samples, + } + } + + pub fn new_out_of_place_f32(config: &AudioPortConfig, samples: u32) -> Self { + Self::new( + (0..config.inputs.len()) + .map(AudioBufferPort::Input) + .chain((0..config.outputs.len()).map(AudioBufferPort::Output)) + .map(|port| port.create_buffer(config, samples, false)) + .collect(), + samples, + ) + } + + pub fn new_out_of_place_f64(config: &AudioPortConfig, samples: u32) -> Self { + Self::new( + (0..config.inputs.len()) + .map(AudioBufferPort::Input) + .chain((0..config.outputs.len()).map(AudioBufferPort::Output)) + .map(|port| port.create_buffer(config, samples, true)) + .collect(), + samples, + ) + } + + pub fn new_in_place_f32(config: &AudioPortConfig, samples: u32) -> Result { + Ok(Self::new( + resolve_in_place_pairs(config)? + .iter() + .map(|port| port.create_buffer(config, samples, false)) + .collect(), + samples, + )) + } + + pub fn new_in_place_f64(config: &AudioPortConfig, samples: u32) -> Result { + Ok(Self::new( + resolve_in_place_pairs(config)? + .iter() + .map(|port| port.create_buffer(config, samples, true)) + .collect(), + samples, + )) + } + + #[allow(clippy::obfuscated_if_else)] + pub fn process( + &mut self, + f: impl FnOnce(&[clap_audio_buffer], &mut [clap_audio_buffer]) -> Result, + ) -> Result { + for buffer in self.buffers.iter() { + if let Some(input) = buffer.port().input() { + let clap = &mut self.clap_inputs[input]; + let ptrs = &mut self.ptrs_inputs[input]; + + clap.data32 = buffer.is_32bit().then_some(ptrs.as_mut_ptr()).unwrap_or_default() as *mut _; + clap.data64 = buffer.is_64bit().then_some(ptrs.as_mut_ptr()).unwrap_or_default() as *mut _; + clap.channel_count = buffer.channels(); + clap.constant_mask = buffer.input_constant_mask.0; + clap.latency = buffer.input_latency; + + for i in 0..buffer.channels() as usize { + ptrs[i] = buffer.channel_ptr(i as u32); + } + } + + if let Some(output) = buffer.port().output() { + let clap = &mut self.clap_outputs[output]; + let ptrs = &mut self.ptrs_outputs[output]; + + clap.data32 = buffer.is_32bit().then_some(ptrs.as_mut_ptr()).unwrap_or_default() as *mut _; + clap.data64 = buffer.is_64bit().then_some(ptrs.as_mut_ptr()).unwrap_or_default() as *mut _; + clap.channel_count = buffer.channels(); + clap.constant_mask = 0; + clap.latency = 0; + + for i in 0..buffer.channels() as usize { + ptrs[i] = buffer.channel_ptr(i as u32); + } + } + } + + let result = f(&self.clap_inputs, &mut self.clap_outputs)?; + + for buffer in self.buffers.iter_mut() { + if let Some(input) = buffer.port().input() { + let clap = &self.clap_inputs[input]; + let ptrs = &self.ptrs_inputs[input]; + + let ptr32 = buffer.is_32bit().then_some(ptrs.as_ptr()).unwrap_or_default() as *mut _; + let ptr64 = buffer.is_64bit().then_some(ptrs.as_ptr()).unwrap_or_default() as *mut _; + + if clap.data32 != ptr32 + || clap.data64 != ptr64 + || clap.channel_count != buffer.channels() + || clap.constant_mask != buffer.input_constant_mask.0 + || clap.latency != buffer.input_latency + { + anyhow::bail!( + "The plugin modified the input buffer (index {input}) data while processing, which is not \ + allowed." + ); + } + + for i in 0..buffer.channels() as usize { + if ptrs[i] != buffer.channel_ptr(i as u32) { + anyhow::bail!( + "The plugin modified the input buffer (index {input}) channel pointers while processing, \ + which is not allowed." + ); + } + } + } + + if let Some(output) = buffer.port().output() { + let clap = &self.clap_outputs[output]; + let ptrs = &self.ptrs_outputs[output]; + + let ptr32 = buffer.is_32bit().then_some(ptrs.as_ptr()).unwrap_or_default() as *mut _; + let ptr64 = buffer.is_64bit().then_some(ptrs.as_ptr()).unwrap_or_default() as *mut _; + + if clap.data32 != ptr32 || clap.data64 != ptr64 || clap.channel_count != buffer.channels() { + anyhow::bail!( + "The plugin modified the output buffer (index {output}) data while processing, which is not \ + allowed." + ); + } + + for i in 0..buffer.channels() as usize { + if ptrs[i] != buffer.channel_ptr(i as u32) { + anyhow::bail!( + "The plugin modified the output buffer (index {output}) channel pointers while \ + processing, which is not allowed." + ); + } + } + + buffer.output_constant_mask = ConstantMask(clap.constant_mask); + buffer.output_latency = clap.latency; + } + } + + Ok(result) + } + + pub fn samples(&self) -> u32 { + self.samples + } + + pub fn fill_white_noise(&mut self, prng: &mut impl Rng) { + for buffer in self.buffers.iter_mut() { + if buffer.port().input().is_some() { + buffer.fill_white_noise(prng); + } + } + } + + pub fn fill_silence(&mut self) { + for buffer in self.buffers.iter_mut() { + if buffer.port().input().is_some() { + buffer.fill_silence(); + } + } + } + + pub fn num_outputs(&self) -> usize { + self.clap_outputs.len() + } +} + +impl AudioBuffer { + pub fn new(port: AudioBufferPort, channels: u32, samples: u32, is_double: bool) -> Self { + let data = if is_double { + Either::Right(vec![vec![0.0f64; samples as usize].into_boxed_slice(); channels as usize].into_boxed_slice()) + } else { + Either::Left(vec![vec![0.0f32; samples as usize].into_boxed_slice(); channels as usize].into_boxed_slice()) + }; + + Self { + port, + data, + samples, + input_constant_mask: ConstantMask::DYNAMIC, + output_constant_mask: ConstantMask::DYNAMIC, + input_latency: 0, + output_latency: 0, + } + } + + pub fn port(&self) -> AudioBufferPort { + self.port + } + + pub fn set_input_constant_mask(&mut self, mask: ConstantMask) { + self.input_constant_mask = mask; + } + + pub fn get_output_constant_mask(&self) -> ConstantMask { + self.output_constant_mask + } + + #[allow(unused)] + pub fn set_input_latency(&mut self, latency: u32) { + self.input_latency = latency; + } + + #[allow(unused)] + pub fn get_output_latency(&self) -> u32 { + self.output_latency + } + + pub fn fill_white_noise(&mut self, prng: &mut impl Rng) { + for channel in 0..self.channels() { + match self.channel_mut(channel) { + Either::Left(data) => data.fill_with(|| prng.random_range(-1.0..1.0)), + Either::Right(data) => data.fill_with(|| prng.random_range(-1.0..1.0)), + } + } + + self.set_input_constant_mask(ConstantMask::DYNAMIC); + } + + pub fn fill_silence(&mut self) { + self.fill(0.0, 0.0); + self.set_input_constant_mask(ConstantMask::CONSTANT); + } + + pub fn fill(&mut self, value_f32: f32, value_f64: f64) { + for channel in 0..self.channels() { + match self.channel_mut(channel) { + Either::Left(data) => data.fill(value_f32), + Either::Right(data) => data.fill(value_f64), + } + } + } + + pub fn is_64bit(&self) -> bool { + self.data.is_right() + } + + pub fn is_32bit(&self) -> bool { + self.data.is_left() + } + + pub fn samples(&self) -> u32 { + self.samples + } + + pub fn channels(&self) -> u32 { + match &self.data { + Either::Left(data) => data.len() as u32, + Either::Right(data) => data.len() as u32, + } + } + + pub fn channel(&self, channel: u32) -> Either<&[f32], &[f64]> { + match &self.data { + Either::Left(data) => Either::Left(&data[channel as usize]), + Either::Right(data) => Either::Right(&data[channel as usize]), + } + } + + pub fn channel_mut(&mut self, channel: u32) -> Either<&mut [f32], &mut [f64]> { + match &mut self.data { + Either::Left(data) => Either::Left(&mut data[channel as usize]), + Either::Right(data) => Either::Right(&mut data[channel as usize]), + } + } + + pub fn channel_ptr(&self, channel: u32) -> *mut () { + match &self.data { + Either::Left(data) => data[channel as usize].as_ptr() as *mut (), + Either::Right(data) => data[channel as usize].as_ptr() as *mut (), + } + } + + pub fn get(&self, channel: u32, sample: u32) -> Either { + match &self.data { + Either::Left(data) => Either::Left(data[channel as usize][sample as usize]), + Either::Right(data) => Either::Right(data[channel as usize][sample as usize]), + } + } +} + +impl AudioBufferPort { + pub fn input(&self) -> Option { + match self { + AudioBufferPort::Input(index) => Some(*index), + AudioBufferPort::Inplace(index, _) => Some(*index), + AudioBufferPort::Output(_) => None, + } + } + + pub fn output(&self) -> Option { + match self { + AudioBufferPort::Output(index) => Some(*index), + AudioBufferPort::Inplace(_, index) => Some(*index), + AudioBufferPort::Input(_) => None, + } + } + + pub fn create_buffer(self, config: &AudioPortConfig, samples: u32, is_double: bool) -> AudioBuffer { + match self { + AudioBufferPort::Input(index) => AudioBuffer::new( + self, + config.inputs[index].channel_count, + samples, + is_double && config.inputs[index].supports_double_sample_size, + ), + AudioBufferPort::Output(index) => AudioBuffer::new( + self, + config.outputs[index].channel_count, + samples, + is_double && config.outputs[index].supports_double_sample_size, + ), + AudioBufferPort::Inplace(input_index, output_index) => AudioBuffer::new( + self, + config.inputs[input_index].channel_count, + samples, + is_double + && config.inputs[input_index].supports_double_sample_size + && config.outputs[output_index].supports_double_sample_size, + ), + } + } +} + +unsafe impl Send for AudioBuffers {} +unsafe impl Sync for AudioBuffers {} + +impl Deref for AudioBuffers { + type Target = [AudioBuffer]; + + fn deref(&self) -> &Self::Target { + &self.buffers + } +} + +impl DerefMut for AudioBuffers { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.buffers + } +} + +/// Resolve the in-place pairs from the given audio port configuration. +/// +/// Returns an error if there are any inconsistencies, such as an input or output port +/// referencing a non-existent in-place pair. +fn resolve_in_place_pairs(config: &AudioPortConfig) -> Result> { + fn is_same_layout(port: &AudioPort, other: &AudioPort) -> bool { + port.channel_count == other.channel_count + && port.port_type == other.port_type + && port.supports_double_sample_size == other.supports_double_sample_size + && port.requires_common_sample_size == other.requires_common_sample_size + && port.prefers_double_sample_size == other.prefers_double_sample_size + } + + let mut ports = vec![]; + let mut in_place: HashMap<(u32, u32), (Option, Option)> = HashMap::new(); + + for (index, port) in config.inputs.iter().enumerate() { + if let Some(inplace_id) = port.in_place_pair { + in_place.entry((port.id, inplace_id)).or_default().0 = Some(index); + } else { + ports.push(AudioBufferPort::Input(index)); + } + } + + for (index, port) in config.outputs.iter().enumerate() { + if let Some(inplace_id) = port.in_place_pair { + in_place.entry((inplace_id, port.id)).or_default().1 = Some(index); + } else { + ports.push(AudioBufferPort::Output(index)); + } + } + + for ((input_id, output_id), (input, output)) in in_place { + match (input, output) { + (None, Some(output)) => anyhow::bail!( + "Output port {output} has an in-place pair ({input_id}), but the corresponding input port does not \ + exist." + ), + (Some(input), None) => anyhow::bail!( + "Input port {input} has an in-place pair ({output_id}), but the corresponding output port does not \ + exist." + ), + (Some(input), Some(output)) => { + if !is_same_layout(&config.inputs[input], &config.outputs[output]) { + anyhow::bail!( + "Input port {input} and output port {output} are configured as an in-place pair, but they \ + have different flags/layouts.", + ); + } + + ports.push(AudioBufferPort::Inplace(input, output)); + } + _ => {} + } + } + + Ok(ports) +} diff --git a/src/plugin/process/events.rs b/src/plugin/process/events.rs new file mode 100644 index 0000000..e638a93 --- /dev/null +++ b/src/plugin/process/events.rs @@ -0,0 +1,455 @@ +use crate::cli::fail_test; +use crate::cli::tracing::{Recordable, Recorder, Span, record}; +use crate::plugin::util::{CHECK_POINTER, Proxy, Proxyable}; +use clap_sys::events::*; +use std::fmt::Debug; +use std::sync::Mutex; + +#[derive(Debug)] +pub struct InputEventQueue(Mutex>); + +#[derive(Debug)] +pub struct OutputEventQueue(Mutex>); + +/// An event sent to or from the plugin. This uses an enum to make the implementation simple and +/// correct at the cost of more wasteful memory usage. +#[derive(Debug, Clone)] +#[repr(C, align(8))] +pub enum Event { + /// `CLAP_EVENT_NOTE_ON`, `CLAP_EVENT_NOTE_OFF`, `CLAP_EVENT_NOTE_CHOKE`, or `CLAP_EVENT_NOTE_END`. + Note(clap_event_note), + /// `CLAP_EVENT_NOTE_EXPRESSION`. + NoteExpression(clap_event_note_expression), + /// `CLAP_EVENT_MIDI`. + Midi(clap_event_midi), + /// `CLAP_EVENT_MIDI2`. + Midi2(clap_event_midi2), + /// `CLAP_EVENT_MIDI_SYSEX`. + Sysex(clap_event_midi_sysex), + /// `CLAP_EVENT_PARAM_VALUE`. + ParamValue(clap_event_param_value), + /// `CLAP_EVENT_PARAM_MOD`. + ParamMod(clap_event_param_mod), + /// `CLAP_EVENT_PARAM_GESTURE_BEGIN` or `CLAP_EVENT_PARAM_GESTURE_END`. + ParamGesture(clap_event_param_gesture), + /// `CLAP_EVENT_TRANSPORT`. + Transport(clap_event_transport), + /// An unhandled event type. This is only used when the plugin outputs an event we don't handle + /// or recognize. + Unknown(clap_event_header), +} + +impl Proxyable for InputEventQueue { + type Vtable = clap_input_events; + + fn init(&self) -> Self::Vtable { + clap_input_events { + ctx: CHECK_POINTER, + size: Some(Self::size), + get: Some(Self::get), + } + } +} + +impl Proxyable for OutputEventQueue { + type Vtable = clap_output_events; + + fn init(&self) -> Self::Vtable { + clap_output_events { + ctx: CHECK_POINTER, + try_push: Some(Self::try_push), + } + } +} + +impl InputEventQueue { + pub fn new() -> Proxy { + Proxy::new(Self(Mutex::new(Vec::new()))) + } + + pub fn clear(&self) { + let mut events = self.0.lock().unwrap(); + events.clear(); + } + + pub fn last_event_time(&self) -> Option { + let events = self.0.lock().unwrap(); + events.last().map(|event| event.header().time) + } + + pub fn add_events(&self, extend: impl IntoIterator) { + let mut events = self.0.lock().unwrap(); + let is_empty = events.is_empty(); + events.extend(extend); + if !is_empty { + events.sort_by_key(|event| event.header().time); + } + } + + unsafe extern "C" fn size(list: *const clap_input_events) -> u32 { + let span = Span::begin("clap_input_events::size", ()); + + let state = unsafe { + Proxy::::from_vtable(list).unwrap_or_else(|e| { + fail_test!("clap_input_events::size: {}", e); + }) + }; + + if Proxy::vtable(&state).ctx != CHECK_POINTER { + fail_test!("clap_input_events::size: plugin messed with the 'ctx' pointer"); + } + + let events = state.0.lock().unwrap(); + span.finish(record!(result: events.len() as u32)); + events.len() as u32 + } + + unsafe extern "C" fn get(list: *const clap_input_events, index: u32) -> *const clap_event_header { + let span = Span::begin("clap_input_events::get", record!(index: index)); + + let state = unsafe { + Proxy::::from_vtable(list).unwrap_or_else(|e| { + fail_test!("clap_input_events::size: {}", e); + }) + }; + + if Proxy::vtable(&state).ctx != CHECK_POINTER { + fail_test!("clap_input_events::size: plugin messed with the 'ctx' pointer"); + } + + let events = state.0.lock().unwrap(); + match events.get(index as usize) { + Some(event) => { + span.finish(record!(event: event)); + event.header() + } + None => { + log::warn!( + "The plugin tried to get an out of bounds event with index {index} ({} total events)", + events.len() + ); + std::ptr::null() + } + } + } +} + +impl OutputEventQueue { + pub fn new() -> Proxy { + Proxy::new(Self(Mutex::new(Vec::new()))) + } + + pub fn clear(&self) { + self.0.lock().unwrap().clear(); + } + + pub fn read(&self) -> Vec { + self.0.lock().unwrap().clone() + } + + unsafe extern "C" fn try_push(list: *const clap_output_events, event: *const clap_event_header) -> bool { + let span = Span::begin("clap_output_events::try_push", ()); + let state = unsafe { + Proxy::::from_vtable(list).unwrap_or_else(|e| { + fail_test!("clap_output_events::try_push: {}", e); + }) + }; + + if Proxy::vtable(&state).ctx != CHECK_POINTER { + fail_test!("clap_output_events::try_push: plugin messed with the 'ctx' pointer"); + } + + if event.is_null() { + fail_test!("clap_output_events::try_push: 'event' pointer is null"); + } + + // The monotonicity of the plugin's event insertion order is checked as part of the output + // consistency checks + + let event = unsafe { Event::from_raw(event) }; + span.finish(record!(event: event)); + state.0.lock().unwrap().push(event); + true + } +} + +impl Event { + /// Parse an event from a plugin-provided pointer. Returns an error if the pointer as a null pointer + pub unsafe fn from_raw(ptr: *const clap_event_header) -> Self { + assert!(!ptr.is_null(), "Null pointer provided for 'clap_event_header'."); + + unsafe { + match ((*ptr).space_id, ((*ptr).type_)) { + ( + CLAP_CORE_EVENT_SPACE_ID, + CLAP_EVENT_NOTE_ON | CLAP_EVENT_NOTE_OFF | CLAP_EVENT_NOTE_CHOKE | CLAP_EVENT_NOTE_END, + ) => Event::Note(*(ptr as *const clap_event_note)), + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_EXPRESSION) => { + Event::NoteExpression(*(ptr as *const clap_event_note_expression)) + } + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_VALUE) => { + Event::ParamValue(*(ptr as *const clap_event_param_value)) + } + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_MOD) => { + Event::ParamMod(*(ptr as *const clap_event_param_mod)) + } + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_GESTURE_BEGIN | CLAP_EVENT_PARAM_GESTURE_END) => { + Event::ParamGesture(*(ptr as *const clap_event_param_gesture)) + } + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI) => Event::Midi(*(ptr as *const clap_event_midi)), + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI2) => Event::Midi2(*(ptr as *const clap_event_midi2)), + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI_SYSEX) => { + Event::Sysex(*(ptr as *const clap_event_midi_sysex)) + } + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_TRANSPORT) => { + Event::Transport(*(ptr as *const clap_event_transport)) + } + (_, _) => Event::Unknown(*ptr), + } + } + } + + /// Get a a reference to the event's header. + pub fn header(&self) -> &clap_event_header { + match self { + Event::Note(event) => &event.header, + Event::NoteExpression(event) => &event.header, + Event::ParamValue(event) => &event.header, + Event::ParamMod(event) => &event.header, + Event::ParamGesture(event) => &event.header, + Event::Midi(event) => &event.header, + Event::Midi2(event) => &event.header, + Event::Sysex(event) => &event.header, + Event::Transport(event) => &event.header, + Event::Unknown(header) => header, + } + } +} + +impl Recordable for Event { + fn record(&self, record: &mut dyn Recorder) { + record.record( + "type", + match (self.header().space_id, self.header().type_) { + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_ON) => "CLAP_EVENT_NOTE_ON", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_OFF) => "CLAP_EVENT_NOTE_OFF", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_CHOKE) => "CLAP_EVENT_NOTE_CHOKE", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_END) => "CLAP_EVENT_NOTE_END", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_EXPRESSION) => "CLAP_EVENT_NOTE_EXPRESSION", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_GESTURE_BEGIN) => "CLAP_EVENT_PARAM_GESTURE_BEGIN", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_GESTURE_END) => "CLAP_EVENT_PARAM_GESTURE_END", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_VALUE) => "CLAP_EVENT_PARAM_VALUE", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_PARAM_MOD) => "CLAP_EVENT_PARAM_MOD", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI) => "CLAP_EVENT_MIDI", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI2) => "CLAP_EVENT_MIDI2", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI_SYSEX) => "CLAP_EVENT_MIDI_SYSEX", + (CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_TRANSPORT) => "CLAP_EVENT_TRANSPORT", + (_, _) => "?", + }, + ); + + record.record("space_id", self.header().space_id); + record.record("type_id", self.header().type_); + record.record("time", self.header().time); + + record.record("flags.is_live", self.header().flags & CLAP_EVENT_IS_LIVE != 0); + record.record("flags.dont_record", self.header().flags & CLAP_EVENT_DONT_RECORD != 0); + + match self { + Event::Note(event) => { + record.record("info.note_id", event.note_id); + record.record("info.key", event.key); + record.record("info.port", event.port_index); + record.record("info.channel", event.channel); + record.record("info.velocity", event.velocity); + } + Event::NoteExpression(event) => { + record.record("info.note_id", event.note_id); + record.record("info.port_index", event.port_index); + record.record("info.key", event.key); + record.record("info.channel", event.channel); + + record.record( + "expression", + match event.expression_id { + CLAP_NOTE_EXPRESSION_VOLUME => "CLAP_NOTE_EXPRESSION_VOLUME", + CLAP_NOTE_EXPRESSION_PAN => "CLAP_NOTE_EXPRESSION_PAN", + CLAP_NOTE_EXPRESSION_TUNING => "CLAP_NOTE_EXPRESSION_TUNING", + CLAP_NOTE_EXPRESSION_VIBRATO => "CLAP_NOTE_EXPRESSION_VIBRATO", + CLAP_NOTE_EXPRESSION_BRIGHTNESS => "CLAP_NOTE_EXPRESSION_BRIGHTNESS", + CLAP_NOTE_EXPRESSION_PRESSURE => "CLAP_NOTE_EXPRESSION_PRESSURE", + CLAP_NOTE_EXPRESSION_EXPRESSION => "CLAP_NOTE_EXPRESSION_EXPRESSION", + _ => "?", + }, + ); + + record.record("info.expression_id", event.expression_id); + record.record("info.value", event.value); + } + Event::ParamValue(event) => { + record.record("info.param_id", event.param_id); + record.record("info.value", event.value); + record.record("info.note_id", event.note_id); + record.record("info.port_index", event.port_index); + record.record("info.key", event.key); + record.record("info.channel", event.channel); + } + Event::ParamMod(event) => { + record.record("info.param_id", event.param_id); + record.record("info.amount", event.amount); + record.record("info.note_id", event.note_id); + record.record("info.port_index", event.port_index); + record.record("info.key", event.key); + record.record("info.channel", event.channel); + } + Event::ParamGesture(event) => { + record.record("info.param_id", event.param_id); + } + Event::Midi(event) => { + record.record("info.port_index", event.port_index); + record.record("info.raw", format_args!("{:X?}", event.data)); + + if let Some(midi_event) = MidiEvent::parse(event.data) { + record.record("info.midi", midi_event); + } + } + Event::Midi2(event) => { + record.record("info.port_index", event.port_index); + record.record("info.raw", format_args!("{:X?}", event.data)); + } + Event::Sysex(event) => { + record.record("info.port_index", event.port_index); + + if event.buffer.is_null() { + record.record("info.data", ""); + } else { + record.record( + "info.data", + format_args!("{:X?}", unsafe { + std::slice::from_raw_parts(event.buffer, event.size as usize) + }), + ); + } + } + Event::Transport(event) => { + record.record("info.transport", event); + } + Event::Unknown(..) => {} + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum MidiEvent { + NoteOn { key: u8, velocity: u8, channel: u8 }, + NoteOff { key: u8, velocity: u8, channel: u8 }, + NotePressure { key: u8, pressure: u8, channel: u8 }, + ControlChange { param: u8, value: u8, channel: u8 }, + ProgramChange { program: u8, channel: u8 }, + ChannelPressure { pressure: u8, channel: u8 }, + PitchBend { value: f32, channel: u8 }, +} + +impl MidiEvent { + pub fn into_bytes(self) -> [u8; 3] { + match self { + MidiEvent::NoteOn { key, velocity, channel } => [0x90 | channel, key & 0x7F, velocity & 0x7F], + MidiEvent::NoteOff { key, velocity, channel } => [0x80 | channel, key & 0x7F, velocity & 0x7F], + MidiEvent::NotePressure { key, pressure, channel } => [0xA0 | channel, key & 0x7F, pressure & 0x7F], + MidiEvent::ControlChange { param, value, channel } => [0xB0 | channel, param & 0x7F, value & 0x7F], + MidiEvent::ProgramChange { program, channel } => [0xC0 | channel, program & 0x7F, 0], + MidiEvent::ChannelPressure { pressure, channel } => [0xD0 | channel, pressure & 0x7F, 0], + MidiEvent::PitchBend { value, channel } => { + let value = (value.clamp(-1.0, 1.0) * 8192.0) as i16 + 8192; + [0xE0 | channel, (value & 0x7F) as u8, ((value >> 7) & 0x7F) as u8] + } + } + } + + pub fn parse([a, b, c]: [u8; 3]) -> Option { + let status = a & 0xF0; + let channel = a & 0x0F; + + match status { + 0x80 => Some(MidiEvent::NoteOff { + key: b, + velocity: c, + channel, + }), + 0x90 if c == 0 => Some(MidiEvent::NoteOff { + key: b, + velocity: 0, + channel, + }), + 0x90 => Some(MidiEvent::NoteOn { + key: b, + velocity: c, + channel, + }), + 0xA0 => Some(MidiEvent::NotePressure { + key: b, + pressure: c, + channel, + }), + 0xB0 => Some(MidiEvent::ControlChange { + param: b, + value: c, + channel, + }), + 0xC0 => Some(MidiEvent::ProgramChange { program: b, channel }), + 0xD0 => Some(MidiEvent::ChannelPressure { pressure: b, channel }), + 0xE0 => { + let value = ((c as u16) << 7) | (b as u16); + let value = (value as i32 - 8192) as f32 / 8192.0; + Some(MidiEvent::PitchBend { value, channel }) + } + _ => None, + } + } +} + +impl Recordable for MidiEvent { + fn record(&self, record: &mut dyn Recorder) { + match self { + MidiEvent::NoteOn { key, velocity, channel } => { + record.record("type", "Note On"); + record.record("key", *key); + record.record("velocity", *velocity); + record.record("channel", *channel); + } + MidiEvent::NoteOff { key, velocity, channel } => { + record.record("type", "Note Off"); + record.record("key", *key); + record.record("velocity", *velocity); + record.record("channel", *channel); + } + MidiEvent::NotePressure { key, pressure, channel } => { + record.record("type", "Aftertouch"); + record.record("key", *key); + record.record("pressure", *pressure); + record.record("channel", *channel); + } + MidiEvent::ControlChange { param, value, channel } => { + record.record("type", "Control Change"); + record.record("control", *param); + record.record("value", *value); + record.record("channel", *channel); + } + MidiEvent::ProgramChange { program, channel } => { + record.record("type", "Program Change"); + record.record("program", *program); + record.record("channel", *channel); + } + MidiEvent::ChannelPressure { pressure, channel } => { + record.record("type", "Channel Pressure"); + record.record("pressure", *pressure); + record.record("channel", *channel); + } + MidiEvent::PitchBend { value, channel } => { + record.record("type", "Pitch Wheel"); + record.record("value", *value); + record.record("channel", *channel); + } + } + } +} diff --git a/src/plugin/process/transport.rs b/src/plugin/process/transport.rs new file mode 100644 index 0000000..f8dc636 --- /dev/null +++ b/src/plugin/process/transport.rs @@ -0,0 +1,229 @@ +use crate::cli::tracing::{Recordable, Recorder}; +use clap_sys::events::*; +use clap_sys::fixedpoint::*; + +/// The current transport state. This can be modified between process calls to simulate +/// transport changes. +#[derive(Debug, Clone, Default)] +pub struct TransportState { + /// The current sample position. + pub sample_pos: Option, + + /// When true, `null` is passed as the transport pointer to the plugin. + pub is_freerun: bool, + + /// Whether playback is active. Sets [`CLAP_TRANSPORT_IS_PLAYING`] flag. + pub is_playing: bool, + + /// Whether recording is active. Sets [`CLAP_TRANSPORT_IS_RECORDING`] flag. + pub is_recording: bool, + + /// Whether the transport is currently within the preroll section. Sets [`CLAP_TRANSPORT_IS_WITHIN_PRE_ROLL`] flag. + pub is_within_preroll: bool, + + /// Current tempo in BPM and its increment per sample. Sets [`CLAP_TRANSPORT_HAS_TEMPO`] flag. + pub tempo: Option<(f64, f64)>, + + /// Current time signature as (numerator, denominator). Sets [`CLAP_TRANSPORT_HAS_TIME_SIGNATURE`] flag. + pub time_signature: Option<(u16, u16)>, + + /// Current position in beats. Sets [`CLAP_TRANSPORT_HAS_BEATS_TIMELINE`] flag. + pub position_beats: Option, + + /// Current position in seconds. Sets [`CLAP_TRANSPORT_HAS_SECONDS_TIMELINE`] flag. + pub position_seconds: Option, +} + +impl TransportState { + /// Create a dummy transport state with reasonable default values. + /// Used for most tests as "default" transport state. + /// + /// Use [`TransportState::default()`] if you want an "empty" transport state instead. + pub fn dummy() -> Self { + TransportState { + sample_pos: Some(0), + is_freerun: false, + is_playing: false, + is_recording: false, + is_within_preroll: false, + tempo: Some((120.0, 0.0)), + time_signature: Some((4, 4)), + position_beats: Some(0.0), + position_seconds: Some(0.0), + } + } + + /// Advance the transport state by the given number of samples at the specified sample rate. + pub fn advance(&mut self, samples: i64, sample_rate: f64) { + if let Some(sample_pos) = &mut self.sample_pos { + *sample_pos = sample_pos.saturating_add_signed(samples); + } + + if self.is_playing + && let Some(position_seconds) = &mut self.position_seconds + { + *position_seconds += samples as f64 / sample_rate; + } + + if let Some((tempo, tempo_inc)) = &mut self.tempo { + let tempo_start = *tempo; + let tempo_end = tempo_start + (*tempo_inc * samples as f64); + *tempo = tempo_end; + + if self.is_playing + && let Some(position_beats) = &mut self.position_beats + { + // Integrate tempo over the sample block using the trapezoidal rule + *position_beats += (samples as f64 * (tempo_end + tempo_start) / 60.0 * 0.5) / sample_rate; + } + } + } + + /// Convert the transport state to a CLAP transport event. + pub fn as_clap_transport(&self, offset: u32) -> clap_event_transport { + let mut flags = 0; + flags |= self.is_playing as u32 * CLAP_TRANSPORT_IS_PLAYING; + flags |= self.is_recording as u32 * CLAP_TRANSPORT_IS_RECORDING; + flags |= self.is_within_preroll as u32 * CLAP_TRANSPORT_IS_WITHIN_PRE_ROLL; + flags |= self.position_beats.is_some() as u32 * CLAP_TRANSPORT_HAS_BEATS_TIMELINE; + flags |= self.position_seconds.is_some() as u32 * CLAP_TRANSPORT_HAS_SECONDS_TIMELINE; + flags |= self.tempo.is_some() as u32 * CLAP_TRANSPORT_HAS_TEMPO; + flags |= self.time_signature.is_some() as u32 * CLAP_TRANSPORT_HAS_TIME_SIGNATURE; + + clap_event_transport { + flags, + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_TRANSPORT, + flags: 0, + }, + + // sending intentional invalid values when the info is not available + // the plugin **must** check the flags to see what info is valid + song_pos_beats: self + .position_beats + .map(|b| (b * CLAP_BEATTIME_FACTOR as f64).round() as i64) + .unwrap_or(i64::MIN), + song_pos_seconds: self + .position_seconds + .map(|s| (s * CLAP_SECTIME_FACTOR as f64).round() as i64) + .unwrap_or(i64::MIN), + tempo: self.tempo.map(|(t, _)| t).unwrap_or(f64::NAN), + tempo_inc: self.tempo.map(|(_, ti)| ti).unwrap_or(f64::NAN), + loop_start_beats: i64::MAX, + loop_end_beats: i64::MIN, + loop_start_seconds: i64::MAX, + loop_end_seconds: i64::MIN, + bar_start: 0, + bar_number: 0, // TODO: implement those 2 + tsig_num: self.time_signature.map(|(n, _)| n).unwrap_or(u16::MAX), + tsig_denom: self.time_signature.map(|(_, d)| d).unwrap_or(0), + } + } +} + +/// A constant mask for audio processing. Each bit represents whether the corresponding audio channel +/// is constant (1) or not (0). +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ConstantMask(pub u64); + +impl ConstantMask { + pub const DYNAMIC: Self = ConstantMask(0); + pub const CONSTANT: Self = ConstantMask(u64::MAX); + + pub fn with_channel_constant(mut self, channel: u32) -> Self { + self.0 |= 1u64.unbounded_shl(channel); + self + } + + /// Check if the specified channel marked as constant. + pub fn is_channel_constant(&self, channel: u32) -> bool { + self.0 & 1u64.unbounded_shl(channel) != 0 + } + + pub fn are_all_channels_constant(&self, n: u32) -> bool { + let mask = (1u64.unbounded_shl(n)).wrapping_sub(1); + (self.0 & mask) == mask + } +} + +impl std::fmt::Debug for ConstantMask { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ConstantMask(0b{:064b})", self.0) + } +} + +impl Recordable for clap_event_transport { + fn record(&self, record: &mut dyn Recorder) { + record.record("flags.is_playing", self.flags & CLAP_TRANSPORT_IS_PLAYING != 0); + record.record("flags.is_recording", self.flags & CLAP_TRANSPORT_IS_RECORDING != 0); + record.record( + "flags.is_within_preroll", + self.flags & CLAP_TRANSPORT_IS_WITHIN_PRE_ROLL != 0, + ); + record.record("flags.is_loop_active", self.flags & CLAP_TRANSPORT_IS_LOOP_ACTIVE != 0); + record.record( + "flags.has_beats_timeline", + self.flags & CLAP_TRANSPORT_HAS_BEATS_TIMELINE != 0, + ); + record.record( + "flags.has_seconds_timeline", + self.flags & CLAP_TRANSPORT_HAS_SECONDS_TIMELINE != 0, + ); + record.record( + "flags.has_time_signature", + self.flags & CLAP_TRANSPORT_HAS_TIME_SIGNATURE != 0, + ); + record.record("flags.has_tempo", self.flags & CLAP_TRANSPORT_HAS_TEMPO != 0); + + if self.flags & CLAP_TRANSPORT_HAS_TEMPO != 0 { + record.record("tempo", self.tempo); + record.record("tempo_inc", self.tempo_inc); + } + + if self.flags & CLAP_TRANSPORT_HAS_TIME_SIGNATURE != 0 { + record.record("time_signature", format_args!("{}/{}", self.tsig_num, self.tsig_denom)); + } + + if self.flags & CLAP_TRANSPORT_HAS_BEATS_TIMELINE != 0 { + record.record("bar_start", self.bar_start); + record.record("bar_number", self.bar_number); + + record.record( + "song_pos_beats", + self.song_pos_beats as f64 / CLAP_BEATTIME_FACTOR as f64, + ); + + if self.flags & CLAP_TRANSPORT_IS_LOOP_ACTIVE != 0 { + record.record( + "loop_start_beats", + self.loop_start_beats as f64 / CLAP_BEATTIME_FACTOR as f64, + ); + record.record( + "loop_end_beats", + self.loop_end_beats as f64 / CLAP_BEATTIME_FACTOR as f64, + ); + } + } + + if self.flags & CLAP_TRANSPORT_HAS_SECONDS_TIMELINE != 0 { + record.record( + "song_pos_seconds", + self.song_pos_seconds as f64 / CLAP_SECTIME_FACTOR as f64, + ); + + if self.flags & CLAP_TRANSPORT_IS_LOOP_ACTIVE != 0 { + record.record( + "loop_start_seconds", + self.loop_start_seconds as f64 / CLAP_SECTIME_FACTOR as f64, + ); + record.record( + "loop_end_seconds", + self.loop_end_seconds as f64 / CLAP_SECTIME_FACTOR as f64, + ); + } + } + } +} diff --git a/src/plugin/util.rs b/src/plugin/util.rs new file mode 100644 index 0000000..8897f58 --- /dev/null +++ b/src/plugin/util.rs @@ -0,0 +1,261 @@ +//! Various utility functions for the plugin host. + +use anyhow::{Context, Result}; +use std::ffi::{CStr, CString, c_char, c_void}; +use std::sync::OnceLock; + +/// Call a CLAP function. This is needed because even though none of CLAP's functions are allowed to +/// be null pointers, people will still use null pointers for some of the function arguments. This +/// also happens in the official `clap-helpers`. As such, these functions are now `Option` +/// optional function pointers in `clap-sys`. This macro asserts that the pointer is not null, and +/// prints a nicely formatted error message containing the struct and funciton name if it is. It +/// also emulates C's syntax for accessing fields struct through a pointer. Except that it uses `=>` +/// instead of `->`. Because that sounds like it would be hilarious. +macro_rules! clap_call { + { $obj_ptr:expr=>$function_name:ident($($args:expr),* $(, )?) } => { + match (*$obj_ptr).$function_name { + Some(function_ptr) => function_ptr($($args),*), + None => $crate::cli::fail_test!("'{}::{}' is a null pointer, but this is not allowed", $crate::plugin::util::type_name_of_ptr($obj_ptr), stringify!($function_name)), + } + } +} + +pub(crate) use clap_call; + +/// A pointer used for fields like `host_data` that can be checked for validity. +/// We do not use `host_data` etc. directly, instead we rely on the offsets within the owner struct (See [`crate::plugin::instance::PluginShared::wrap`] for more info) +pub const CHECK_POINTER: *mut c_void = 0xDEADCAFE as *mut c_void; + +/// Similar to, [`std::any::type_name_of_val()`], but on stable Rust, and stripping away the pointer +/// part. +#[must_use] +#[doc(hidden)] +pub fn type_name_of_ptr(_ptr: *const T) -> &'static str { + std::any::type_name::() +} + +/// Convert a `*const c_char` to a `String`. Returns `Ok(None)` if the pointer is a null pointer or +/// if the string is not valid UTF-8. This only returns an error if the string contains invalid +/// UTF-8. +/// +/// # Safety +/// +/// `ptr` should point to a valid null terminated C-string. +pub unsafe fn cstr_ptr_to_string(ptr: *const c_char) -> Result> { + if ptr.is_null() { + return Ok(None); + } + + unsafe { + CStr::from_ptr(ptr) + .to_str() + .map(|str| Some(String::from(str))) + .context("Error while parsing UTF-8") + } +} + +/// The same as [`cstr_ptr_to_string()`], but it returns an error if the string is empty. +pub unsafe fn cstr_ptr_to_mandatory_string(ptr: *const c_char) -> Result { + unsafe { + match cstr_ptr_to_string(ptr)? { + Some(string) if string.is_empty() => anyhow::bail!("The string is empty."), + Some(string) => Ok(string), + None => anyhow::bail!("The string is a null pointer."), + } + } +} + +/// The same as [`cstr_ptr_to_string()`], but it treats empty strings as missing. Useful for parsing +/// optional fields from structs. +pub unsafe fn cstr_ptr_to_optional_string(ptr: *const c_char) -> Result> { + unsafe { + match cstr_ptr_to_string(ptr)? { + Some(string) if string.is_empty() => Ok(None), + x => Ok(x), + } + } +} + +/// Convert a null terminated `*const *const c_char` array to a `Vec`. Returns `None` if the +/// first pointer is a null pointer. Returns an error if any of the strings are not valid UTF-8. +/// +/// # Safety +/// +/// `ptr` should point to a valid null terminated C-string array. +pub unsafe fn cstr_array_to_vec(mut ptr: *const *const c_char) -> Result>> { + unsafe { + if ptr.is_null() { + return Ok(None); + } + + let mut strings = Vec::new(); + while !(*ptr).is_null() { + // We already checked for null pointers, so we can safely unwrap this + strings.push(cstr_ptr_to_string(*ptr)?.unwrap()); + ptr = ptr.offset(1); + } + + Ok(Some(strings)) + } +} + +/// Convert a `c_char` slice to a `String`. Returns an error if the slice did not contain a null +/// byte, or if the string is not valid UTF-8. +pub fn c_char_slice_to_string(slice: &[c_char]) -> Result { + // `from_bytes_until_nul` is still unstable, so we'll YOLO it for now by checking if the slice + // contains a null byte and then treating it as a pointer if it does + if !slice.contains(&0) { + anyhow::bail!("The string buffer does not contain a null byte.") + } + + unsafe { CStr::from_ptr(slice.as_ptr()) } + .to_str() + .context("Error while parsing UTF-8") + .map(String::from) +} + +pub fn validator_version() -> &'static CStr { + static VERSION: OnceLock = OnceLock::new(); + VERSION + .get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).unwrap()) + .as_c_str() +} + +pub use proxy::{Proxy, Proxyable}; + +mod proxy { + use anyhow::Result; + use rustc_hash::FxHashMap; + use std::any::{TypeId, type_name}; + use std::ops::Deref; + use std::pin::Pin; + use std::sync::{Arc, RwLock}; + + struct TrackStatus { + type_id: TypeId, + type_name: &'static str, + is_alive: bool, + } + + static OBJECTS: RwLock>> = RwLock::new(None); + + /// Start tracking the given object pointer. + fn track(obj: *const T) { + let mut objects = OBJECTS.write().unwrap(); + objects.get_or_insert_default().insert( + obj.addr(), + TrackStatus { + type_id: TypeId::of::(), + type_name: type_name::(), + is_alive: true, + }, + ); + } + + /// Stop tracking the given object pointer, any subsequent use will be considered invalid. + fn untrack(obj: *const T) { + let mut objects = OBJECTS.write().unwrap(); + match objects.as_mut().and_then(|x| x.get_mut(&obj.addr())) { + Some(status) if TypeId::of::() == status.type_id => status.is_alive = false, + _ => unreachable!(), + } + } + + /// Check that the given object pointer is valid, of the correct type, and is still alive. + fn check(obj: *const T) -> Result<()> { + if obj.is_null() { + anyhow::bail!("null pointer to {}", type_name::()); + } + + let objects = OBJECTS.read().unwrap(); + let object = objects.as_ref().and_then(|x| x.get(&obj.addr())); + + let Some(object) = object else { + anyhow::bail!("invalid pointer to {}", type_name::()); + }; + + if object.type_id != TypeId::of::() { + anyhow::bail!("expected pointer to {}, got {}", type_name::(), object.type_name); + } + + if !object.is_alive { + anyhow::bail!("{} has expired", type_name::()); + } + + Ok(()) + } + + #[repr(C)] + struct ProxyInner { + vtable: T::Vtable, + data: T, + } + + /// A type that can be proxied to the plugin through a vtable pointer. + /// See [`Proxy`] for more information. + pub trait Proxyable { + type Vtable: 'static; + + fn init(&self) -> Self::Vtable; + } + + /// An object that is accessible to the plugin through a vtable pointer. + /// Implementors of interfaces such as [`clap_host`], [`clap_istream`], and [`clap_ostream`] should be wrapped in this type before being passed to the plugin. + #[repr(transparent)] + pub struct Proxy(Pin>>); + + impl Proxy { + pub fn new(vtable: T) -> Self { + let arc = Arc::pin(ProxyInner { + vtable: vtable.init(), + data: vtable, + }); + + track(&arc.vtable); + Self(arc) + } + + pub fn vtable(this: &Self) -> &T::Vtable { + &this.0.vtable + } + + pub unsafe fn from_vtable(vtable: *const T::Vtable) -> Result { + check(vtable)?; + + unsafe { + let inner = vtable.cast::>(); + Arc::increment_strong_count(inner); + Ok(Proxy(Pin::new_unchecked(Arc::from_raw(inner)))) + } + } + } + + impl Clone for Proxy { + fn clone(&self) -> Self { + Proxy(self.0.clone()) + } + } + + impl Deref for Proxy { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0.data + } + } + + impl std::fmt::Debug for Proxy + where + T: std::fmt::Debug, + { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Proxy").field(&self.0.data).finish() + } + } + + impl Drop for ProxyInner { + fn drop(&mut self) { + untrack(&self.vtable); + } + } +} diff --git a/src/tests.rs b/src/tests.rs index 6f31ad7..cf4cffb 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -9,42 +9,42 @@ //! To facilitate this, the test cases are all identified by variants in an enum, and that enum can //! be converted to and from a string representation. -use anyhow::{Context, Result}; -use clap::ValueEnum; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::ffi::OsStr; -use std::fmt::Display; -use std::fs; use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::str::FromStr; -use strum::IntoEnumIterator; +use std::time::Duration; -use crate::{util, Verbosity}; - -mod plugin; +mod plugin_instance; mod plugin_library; pub mod rng; -pub use plugin::PluginTestCase; +pub use plugin_instance::PluginInstanceTestCase; pub use plugin_library::PluginLibraryTestCase; -/// A test case for testing the behavior of a plugin. This `Test` object contains the result of a -/// test, which is serialized to and from JSON so the test can be run in another process. -#[derive(Debug, Deserialize, Serialize)] -pub struct TestResult { - /// The name of this test. - pub name: String, - /// A description of what this test case has tested. - pub description: String, - /// The outcome of the test. - pub status: TestStatus, +/// A description for a single test invocation. This contains all of the information necessary to run a single test. +#[derive(Deserialize, Serialize, Ord, PartialOrd, Eq, PartialEq, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum TestCase { + PluginLibrary { + test: PluginLibraryTestCase, + path: PathBuf, + }, + + PluginInstance { + test: PluginInstanceTestCase, + path: PathBuf, + plugin_id: String, + }, +} + +#[derive(Eq, PartialOrd, Ord, PartialEq)] +pub enum TestGroup { + PluginLibrary(PathBuf), + PluginInstance(PathBuf, String), } /// The result of running a test. Skipped and failed test may optionally include an explanation for /// why this happened. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] #[serde(tag = "code")] pub enum TestStatus { @@ -63,151 +63,45 @@ pub enum TestStatus { Warning { details: Option }, } -/// Stores all of the available tests and their descriptions. Used solely for pretty printing -/// purposes in `clap-validator list tests`. -#[derive(Debug, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] -pub struct TestList { - pub plugin_library_tests: BTreeMap, - pub plugin_tests: BTreeMap, +pub struct TestResult { + pub test: TestCase, + pub status: TestStatus, + pub duration: Duration, } -/// An abstraction for a test case. This mostly exists because we need two separate kinds of tests -/// (per library and per plugin), and it's good to keep the interface uniform. -pub trait TestCase<'a>: Display + FromStr + IntoEnumIterator + Sized + 'static { - /// The type of the arguments the test cases are parameterized over. This can be an instance of - /// the plugin library and a plugin ID, or just the file path to the plugin library. - type TestArgs; - - /// Get the textual description for a test case. This description won't contain any line breaks, - /// but it may consist of multiple sentences. - fn description(&self) -> String; - - /// Set the arguments for `clap-validator run-single-test` to run this test with the specified - /// arguments. This way the [`run_out_of_process()`][Self::run_out_of_process()] method can be - /// defined in a way that works for all `TestCase`s. - fn set_out_of_process_args(&self, command: &mut Command, args: Self::TestArgs); - - /// Run a test case for a specified arguments in the current, returning the result. If the test - /// cuases the plugin to segfault, then this will obviously not return. See - /// [`run_out_of_process()`][Self::run_out_of_process()] for a generic way to run test cases in - /// a separate process. - /// - /// In the event that this is called for a plugin ID that does not exist within the plugin - /// library, then the test will also be marked as failed. - fn run_in_process(&self, args: Self::TestArgs) -> TestResult; - - /// Run a test case for a plugin in another process, returning the result. If the test cuases the - /// plugin to segfault, then the result will have a status of `TestStatus::Crashed`. If - /// `hide_output` is set, then the tested plugin's output will not be printed to STDIO. - /// - /// The verbosity option is threaded through here so out of process tests use the same logger - /// verbosity as in-process tests. - /// - /// In the event that this is called for a plugin ID that does not exist within the plugin - /// library, then the test will also be marked as failed. - /// - /// This will only return an error if the actual `clap-validator` process call failed. - fn run_out_of_process( - &self, - args: Self::TestArgs, - verbosity: Verbosity, - hide_output: bool, - ) -> Result { - // The idea here is that we'll invoke the same clap-validator binary with a special hidden command - // that runs a single test. This is the reason why test cases must be convertible to and - // from strings. If everything goes correctly, then the child process will write the results - // as JSON to the specified file path. This is intentionaly not done through STDIO since the - // hosted plugin may also write things there, and doing STDIO redirection within the child - // process is more complicated than just writing the result to a temporary file. - - // This temporary file will automatically be removed when this function exits - let output_file_path = tempfile::Builder::new() - .suffix(".json") - .tempfile() - .context("Could not create a temporary file path")? - .into_temp_path(); - let clap_validator_binary = - std::env::current_exe().context("Could not find the path to the current executable")?; - let mut command = Command::new(clap_validator_binary); - - command - .arg("--verbosity") - .arg(verbosity.to_possible_value().unwrap().get_name()) - .arg("run-single-test") - .args([OsStr::new("--output-file"), output_file_path.as_os_str()]); - self.set_out_of_process_args(&mut command, args); - if hide_output { - command.stdout(Stdio::null()); - command.stderr(Stdio::null()); +impl TestCase { + pub fn name(&self) -> String { + match self { + Self::PluginLibrary { test, .. } => test.to_string(), + Self::PluginInstance { test, .. } => test.to_string(), } + } - let exit_status = command - .spawn() - .context("Could not call clap-validator for out-of-process validation")? - // The docs make it seem like this can only fail if the process isn't running, but if - // spawn succeeds then this can never fail: - .wait() - .context("Error while waiting on clap-validator to finish running the test")?; - if !exit_status.success() { - return Ok(TestResult { - name: self.to_string(), - description: self.description(), - status: TestStatus::Crashed { - details: exit_status.to_string(), - }, - }); + pub fn description(&self) -> String { + match self { + Self::PluginLibrary { test, .. } => test.description(), + Self::PluginInstance { test, .. } => test.description(), } - - // At this point, the child process _should_ have written its output to `output_file_path`, - // and we can just parse it from there - let result = - serde_json::from_str(&fs::read_to_string(&output_file_path).with_context(|| { - format!( - "Could not read the child process output from '{}'", - output_file_path.display() - ) - })?) - .context("Could not parse the child process output to JSON")?; - - Ok(result) } - /// Get a writable temporary file handle for this test case. The file will be located at - /// `$TMP_DIR/clap-validator/$plugin_id/$test_name/$file_name`. The temporary files directory is - /// cleared on a new validator run, but the files will persist until then. - fn temporary_file(&self, plugin_id: &str, name: &str) -> Result<(PathBuf, fs::File)> { - let path = util::validator_temp_dir() - .join(plugin_id) - .join(self.to_string()) - .join(name); - if path.exists() { - panic!( - "Tried to create a temporary file at '{}', but this file already exists. This is \ - a bug in clap-validator.", - path.display() - ) + pub fn group(&self) -> TestGroup { + match self { + Self::PluginLibrary { path, .. } => TestGroup::PluginLibrary(path.clone()), + Self::PluginInstance { path, plugin_id, .. } => TestGroup::PluginInstance(path.clone(), plugin_id.clone()), } - - fs::create_dir_all(path.parent().unwrap()) - .context("Could not create the directory for the test's temporary files")?; - let file = - fs::File::create(&path).context("Could not create a temporary file for the test")?; - - Ok((path, file)) } - /// Create a [`TestResult`] for this test case. The test status is wrapped in an anyhow - /// [`Result`] to make writing test cases more ergonomic using the question mark operator. `Err` - /// values are converted to [`TestStatus::Failed`] statuses containing the full error backtrace. - fn create_result(&self, status: Result) -> TestResult { - TestResult { - name: self.to_string(), - description: self.description(), - status: status.unwrap_or_else(|err| TestStatus::Failed { - details: Some(format!("{err:#}")), - }), + pub fn run(&self) -> TestStatus { + match self { + Self::PluginLibrary { test, path } => test.run(path), + Self::PluginInstance { test, path, plugin_id } => test.run(path, plugin_id), } + .unwrap_or_else(|err| { + let err = err.chain().map(|x| x.to_string()).collect::>().join("\n"); + TestStatus::Failed { details: Some(err) } + }) } } @@ -217,9 +111,7 @@ impl TestStatus { pub fn failed_or_warning(&self) -> bool { match self { TestStatus::Success { .. } | TestStatus::Skipped { .. } => false, - TestStatus::Warning { .. } | TestStatus::Crashed { .. } | TestStatus::Failed { .. } => { - true - } + TestStatus::Warning { .. } | TestStatus::Crashed { .. } | TestStatus::Failed { .. } => true, } } @@ -235,15 +127,22 @@ impl TestStatus { } } -impl Default for TestList { - fn default() -> Self { - Self { - plugin_library_tests: PluginLibraryTestCase::iter() - .map(|c| (c.to_string(), c.description())) - .collect(), - plugin_tests: PluginTestCase::iter() - .map(|c| (c.to_string(), c.description())) - .collect(), - } +pub fn temporary_file(test_name: &str, plugin_id: &str, name: &str) -> anyhow::Result<(PathBuf, std::fs::File)> { + let path = crate::cli::validator_temp_dir() + .join(plugin_id) + .join(test_name) + .join(name); + + if path.exists() { + panic!( + "Tried to create a temporary file at '{}', but this file already exists", + path.display() + ) } + + std::fs::create_dir_all(path.parent().unwrap()) + .expect("Could not create the directory for the test's temporary files"); + let file = std::fs::File::create(&path).expect("Could not create a temporary file for the test"); + + Ok((path, file)) } diff --git a/src/tests/plugin.rs b/src/tests/plugin.rs deleted file mode 100644 index 7f46e6e..0000000 --- a/src/tests/plugin.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Tests for individual plugin instances. - -use clap::ValueEnum; -use std::process::Command; - -use super::{TestCase, TestResult}; -use crate::plugin::library::PluginLibrary; - -mod descriptor; -mod params; -mod processing; -mod state; - -pub use processing::ProcessingTest; - -/// The tests for individual CLAP plugins. See the module's heading for more information, and the -/// `description` function below for a description of each test case. -#[derive(strum_macros::Display, strum_macros::EnumString, strum_macros::EnumIter)] -pub enum PluginTestCase { - #[strum(serialize = "descriptor-consistency")] - DescriptorConsistency, - #[strum(serialize = "features-categories")] - FeaturesCategories, - #[strum(serialize = "features-duplicates")] - FeaturesDuplicates, - #[strum(serialize = "process-audio-out-of-place-basic")] - ProcessAudioOutOfPlaceBasic, - #[strum(serialize = "process-note-out-of-place-basic")] - ProcessNoteOutOfPlaceBasic, - #[strum(serialize = "process-note-inconsistent")] - ProcessNoteInconsistent, - #[strum(serialize = "param-conversions")] - ParamConversions, - #[strum(serialize = "param-fuzz-basic")] - ParamFuzzBasic, - #[strum(serialize = "param-set-wrong-namespace")] - ParamSetWrongNamespace, - #[strum(serialize = "state-invalid")] - StateInvalid, - #[strum(serialize = "state-reproducibility-basic")] - StateReproducibilityBasic, - #[strum(serialize = "state-reproducibility-null-cookies")] - StateReproducibilityNullCookies, - #[strum(serialize = "state-reproducibility-flush")] - StateReproducibilityFlush, - #[strum(serialize = "state-buffered-streams")] - StateBufferedStreams, -} - -impl<'a> TestCase<'a> for PluginTestCase { - /// A loaded CLAP plugin library and the ID of the plugin contained within that library that - /// should be tested. - type TestArgs = (&'a PluginLibrary, &'a str); - - fn description(&self) -> String { - match self { - PluginTestCase::DescriptorConsistency => String::from( - "The plugin descriptor returned from the plugin factory and the plugin descriptor \ - stored on the 'clap_plugin object should be equivalent.", - ), - PluginTestCase::FeaturesCategories => String::from( - "The plugin needs to have at least one of the main CLAP category features.", - ), - PluginTestCase::FeaturesDuplicates => { - String::from("The plugin's features array should not contain any duplicates.") - } - PluginTestCase::ProcessAudioOutOfPlaceBasic => String::from( - "Processes random audio through the plugin with its default parameter values and \ - tests whether the output does not contain any non-finite or subnormal values. \ - Uses out-of-place audio processing.", - ), - PluginTestCase::ProcessNoteOutOfPlaceBasic => String::from( - "Sends audio and random note and MIDI events to the plugin with its default \ - parameter values and tests the output for consistency. Uses out-of-place audio \ - processing.", - ), - PluginTestCase::ProcessNoteInconsistent => String::from( - "Sends intentionally inconsistent and mismatching note and MIDI events to the \ - plugin with its default parameter values and tests the output for consistency. \ - Uses out-of-place audio processing.", - ), - PluginTestCase::ParamConversions => String::from( - "Asserts that value to string and string to value conversions are supported for \ - ether all or none of the plugin's parameters, and that conversions between \ - values and strings roundtrip consistently.", - ), - PluginTestCase::ParamFuzzBasic => format!( - "Generates {} sets of random parameter values, sets those on the plugin, and has \ - the plugin process {} buffers of random audio and note events. The plugin passes \ - the test if it doesn't produce any infinite or NaN values, and doesn't crash.", - params::FUZZ_NUM_PERMUTATIONS, - params::FUZZ_RUNS_PER_PERMUTATION - ), - PluginTestCase::ParamSetWrongNamespace => String::from( - "Sends events to the plugin with the 'CLAP_EVENT_PARAM_VALUE' event tyep but with \ - a mismatching namespace ID. Asserts that the plugin's parameter values don't \ - change.", - ), - PluginTestCase::StateInvalid => String::from( - "The plugin should return false when 'clap_plugin_state::load()' is called with \ - an empty state.", - ), - PluginTestCase::StateReproducibilityBasic => String::from( - "Randomizes a plugin's parameters, saves its state, recreates the plugin \ - instance, reloads the state, and then checks whether the parameter values are \ - the same and whether saving the state once more results in the same state file \ - as before. The parameter values are updated using the process function.", - ), - PluginTestCase::StateReproducibilityNullCookies => format!( - "The exact same test as {}, but with all cookies in the parameter events set to \ - null pointers. The plugin should handle this in the same way as the other test \ - case.", - PluginTestCase::StateReproducibilityBasic - ), - PluginTestCase::StateReproducibilityFlush => String::from( - "Randomizes a plugin's parameters, saves its state, recreates the plugin \ - instance, sets the same parameters as before, saves the state again, and then \ - asserts that the two states are identical. The parameter values are set updated \ - using the process function to create the first state, and using the flush \ - function to create the second state.", - ), - PluginTestCase::StateBufferedStreams => format!( - "Performs the same state and parameter reproducibility check as in '{}', but this \ - time the plugin is only allowed to read a small prime number of bytes at a time \ - when reloading and resaving the state.", - PluginTestCase::StateReproducibilityBasic - ), - } - } - - fn set_out_of_process_args(&self, command: &mut Command, (library, plugin_id): Self::TestArgs) { - let test_name = self.to_string(); - - command - .arg( - crate::validator::SingleTestType::Plugin - .to_possible_value() - .unwrap() - .get_name(), - ) - .arg(library.plugin_path()) - .arg(plugin_id) - .arg(test_name); - } - - fn run_in_process(&self, (library, plugin_id): Self::TestArgs) -> TestResult { - let status = match self { - PluginTestCase::DescriptorConsistency => { - descriptor::test_consistency(library, plugin_id) - } - PluginTestCase::FeaturesCategories => { - descriptor::test_features_categories(library, plugin_id) - } - PluginTestCase::FeaturesDuplicates => { - descriptor::test_features_duplicates(library, plugin_id) - } - PluginTestCase::ProcessAudioOutOfPlaceBasic => { - processing::test_process_audio_out_of_place_basic(library, plugin_id) - } - PluginTestCase::ProcessNoteOutOfPlaceBasic => { - processing::test_process_note_out_of_place_basic(library, plugin_id) - } - PluginTestCase::ProcessNoteInconsistent => { - processing::test_process_note_inconsistent(library, plugin_id) - } - PluginTestCase::ParamConversions => params::test_param_conversions(library, plugin_id), - PluginTestCase::ParamFuzzBasic => params::test_param_fuzz_basic(library, plugin_id), - PluginTestCase::ParamSetWrongNamespace => { - params::test_param_set_wrong_namespace(library, plugin_id) - } - PluginTestCase::StateInvalid => state::test_state_invalid(library, plugin_id), - PluginTestCase::StateReproducibilityBasic => { - state::test_state_reproducibility_null_cookies(library, plugin_id, false) - } - PluginTestCase::StateReproducibilityNullCookies => { - state::test_state_reproducibility_null_cookies(library, plugin_id, true) - } - PluginTestCase::StateReproducibilityFlush => { - state::test_state_reproducibility_flush(library, plugin_id) - } - PluginTestCase::StateBufferedStreams => { - state::test_state_buffered_streams(library, plugin_id) - } - }; - - self.create_result(status) - } -} diff --git a/src/tests/plugin/params.rs b/src/tests/plugin/params.rs deleted file mode 100644 index 3725c48..0000000 --- a/src/tests/plugin/params.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Tests that focus on parameters. - -use anyhow::{Context, Result}; -use clap_sys::events::CLAP_EVENT_PARAM_VALUE; -use clap_sys::id::clap_id; -use rand::Rng; -use serde::Serialize; -use std::collections::BTreeMap; - -use super::processing::ProcessingTest; -use super::PluginTestCase; -use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; -use crate::plugin::ext::note_ports::NotePorts; -use crate::plugin::ext::params::Params; -use crate::plugin::ext::Extension; -use crate::plugin::host::Host; -use crate::plugin::instance::process::{Event, ProcessConfig}; -use crate::plugin::library::PluginLibrary; -use crate::tests::rng::{new_prng, NoteGenerator, ParamFuzzer}; -use crate::tests::{TestCase, TestStatus}; - -/// The fixed buffer size to use for these tests. -const BUFFER_SIZE: usize = 512; -/// The number of different parameter combinations to try in the parameter fuzzing tests. -pub const FUZZ_NUM_PERMUTATIONS: usize = 50; -/// How many buffers of [`BUFFER_SIZE`] samples to process at each parameter permutation. This -/// allows the plugin's state to settle in before moving to the next set of parameter values. -pub const FUZZ_RUNS_PER_PERMUTATION: usize = 5; - -/// The file name we'll use to dump the previous parameter values when a fuzzing test fails. -const PREVIOUS_PARAM_VALUES_FILE_NAME: &str = "param-values-previous.json"; -/// The file name we'll use to dump the current parameter values when a fuzzing test fails. -const CURRENT_PARAM_VALUES_FILE_NAME: &str = "param-values-current.json"; - -/// The format parameter values will be written in when the fuzzing test fails. Used only for -/// serialization. -#[derive(Debug, Serialize)] -struct ParamValue<'a> { - id: clap_id, - name: &'a str, - value: f64, -} - -/// The test for `ProcessingTest::ParamConversions`. -pub fn test_param_conversions(library: &PluginLibrary, plugin_id: &str) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - // We keep track of how many parameters support these conversions. A plugin - // should support either conversion either for all of its parameters, or for - // none of them. - const VALUES_PER_PARAM: usize = 6; - let expected_conversions = param_infos.len() * VALUES_PER_PARAM; - - let mut num_supported_value_to_text = 0; - let mut num_supported_text_to_value = 0; - let mut failed_value_to_text_calls: Vec<(String, f64)> = Vec::new(); - let mut failed_text_to_value_calls: Vec<(String, String)> = Vec::new(); - 'param_loop: for (param_id, param_info) in param_infos { - let param_name = ¶m_info.name; - - // For each parameter we'll test this for the minimum and maximum values - // (in case these values have special meanings), and four other random - // values - let values: [f64; VALUES_PER_PARAM] = [ - *param_info.range.start(), - *param_info.range.end(), - prng.gen_range(param_info.range.clone()), - prng.gen_range(param_info.range.clone()), - prng.gen_range(param_info.range.clone()), - prng.gen_range(param_info.range), - ]; - 'value_loop: for starting_value in values { - // If the plugin rounds string representations then `value` may very - // will not roundtrip correctly, so we'll start at the string - // representation - let starting_text = match params.value_to_text(param_id, starting_value)? { - Some(text) => text, - None => { - failed_value_to_text_calls.push((param_name.to_owned(), starting_value)); - continue 'param_loop; - } - }; - num_supported_value_to_text += 1; - let reconverted_value = match params.text_to_value(param_id, &starting_text)? { - Some(value) => value, - // We can't test text to value conversions without a text - // value provided by the plugin, but if the plugin doesn't - // support this then we should still continue testing - // whether the value to text conversion works consistently - None => { - failed_text_to_value_calls.push((param_name.to_owned(), starting_text)); - continue 'value_loop; - } - }; - num_supported_text_to_value += 1; - - let reconverted_text = params - .value_to_text(param_id, reconverted_value)? - .with_context(|| { - format!( - "Failure in repeated value to text conversion for parameter {param_id} \ - ('{param_name}')" - ) - })?; - // Both of these are produced by the plugin, so they should be equal - if starting_text != reconverted_text { - anyhow::bail!( - "Converting {starting_value:?} to a string, back to a value, and then back to \ - a string again for parameter {param_id} ('{param_name}') results in \ - '{starting_text}' -> {reconverted_value:?} -> '{reconverted_text}', which is \ - not consistent." - ); - } - - // And one last hop back for good measure - let final_value = params - .text_to_value(param_id, &reconverted_text)? - .with_context(|| { - format!( - "Failure in repeated text to value conversion for parameter {param_id} \ - ('{param_name}')" - ) - })?; - if final_value != reconverted_value { - anyhow::bail!( - "Converting {starting_value:?} to a string, back to a value, back to a \ - string, and then back to a value again for parameter {param_id} \ - ('{param_name}') results in '{starting_text}' -> {reconverted_value:?} -> \ - '{reconverted_text}' -> {final_value:?}, which is not consistent." - ); - } - } - } - - if !(num_supported_value_to_text == 0 || num_supported_value_to_text == expected_conversions) { - anyhow::bail!( - "'clap_plugin_params::value_to_text()' returned true for \ - {num_supported_value_to_text} out of {expected_conversions} calls. This function is \ - expected to be supported for either none of the parameters or for all of them. \ - Examples of failing conversions were: {failed_value_to_text_calls:#?}" - ); - } - if !(num_supported_text_to_value == 0 || num_supported_text_to_value == expected_conversions) { - anyhow::bail!( - "'clap_plugin_params::text_to_value()' returned true for \ - {num_supported_text_to_value} out of {expected_conversions} calls. This function is \ - expected to be supported for either none of the parameters or for all of them. \ - Examples of failing conversions were: {failed_text_to_value_calls:#?}" - ); - } - - host.callback_error_check() - .context("An error occured during a host callback")?; - if num_supported_value_to_text == 0 || num_supported_text_to_value == 0 { - Ok(TestStatus::Skipped { - details: Some(String::from( - "The plugin's parameters need to support both value to text and text to value \ - conversions for this test.", - )), - }) - } else { - Ok(TestStatus::Success { details: None }) - } -} - -/// The test for `ProcessingTest::ParamFuzzBasic`. -pub fn test_param_fuzz_basic(library: &PluginLibrary, plugin_id: &str) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - // Both audio and note ports are optional - let audio_ports = plugin.get_extension::(); - let note_ports = plugin.get_extension::(); - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let audio_ports_config = audio_ports - .map(|ports| ports.config()) - .transpose() - .context("Could not fetch the plugin's audio port config")?; - let note_ports_config = note_ports - .map(|ports| ports.config()) - .transpose() - .context("Could not fetch the plugin's note port config")? - // Don't try to generate notes if the plugin supports the note ports extension but doesn't - // actually have any note ports. JUCE does this. - .filter(|config| !config.inputs.is_empty()); - let param_infos = params - .info() - .context("Could not fetch the plugin's parameters")?; - - // For each set of runs we'll generate new parameter values, and if the plugin supports notes - // we'll also generate note events. - let param_fuzzer = ParamFuzzer::new(¶m_infos); - let mut note_event_rng = note_ports_config.map(NoteGenerator::new); - - // We'll keep track of the current and the previous set of parameter value so we can write them - // to a file if the test fails - let mut current_events: Option>; - let mut previous_events: Option> = None; - - let (mut input_buffers, mut output_buffers) = audio_ports_config - .unwrap_or_default() - .create_buffers(BUFFER_SIZE); - for permutation_no in 1..=FUZZ_NUM_PERMUTATIONS { - current_events = Some(param_fuzzer.randomize_params_at(&mut prng, 0).collect()); - - let mut have_set_parameters = false; - let run_result = - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)? - .run( - FUZZ_RUNS_PER_PERMUTATION, - ProcessConfig::default(), - |process_data| { - if !have_set_parameters { - *process_data.input_events.events.lock() = - current_events.clone().unwrap(); - have_set_parameters = true; - } - - // Audio and MIDI/note events are randomized in accordance to what the plugin - // supports - if let Some(note_event_rng) = note_event_rng.as_mut() { - // This includes a sort if `random_param_set_events` also contained a queue - note_event_rng.fill_event_queue( - &mut prng, - &process_data.input_events, - BUFFER_SIZE as u32, - )?; - } - process_data.buffers.randomize(&mut prng); - - Ok(()) - }, - ); - - // If the run failed we'll want to write the parameter values to a file first - if run_result.is_err() { - let (previous_param_values_file_path, previous_param_values_file) = - PluginTestCase::ParamFuzzBasic - .temporary_file(plugin_id, PREVIOUS_PARAM_VALUES_FILE_NAME)?; - let (current_param_values_file_path, current_param_values_file) = - PluginTestCase::ParamFuzzBasic - .temporary_file(plugin_id, CURRENT_PARAM_VALUES_FILE_NAME)?; - - let create_param_values_vec = |events: Option>| match events { - Some(events) => events - .into_iter() - .map(|event| match event { - Event::ParamValue(event) => ParamValue { - id: event.param_id, - name: ¶m_infos[&event.param_id].name, - value: event.value, - }, - _ => panic!("Unexpected event type. This is a clap-validator bug."), - }) - .collect(), - None => Vec::new(), - }; - let previous_param_values: Vec = create_param_values_vec(previous_events); - let current_param_values: Vec = create_param_values_vec(current_events); - - serde_json::to_writer_pretty(previous_param_values_file, &previous_param_values)?; - serde_json::to_writer_pretty(current_param_values_file, ¤t_param_values)?; - - // This is a bit weird and there may be a better way to do this, but we only want to - // write the parameter values if we know the run has failed, and we only know the - // filename after writing those values to a file - return Err(run_result - .with_context(|| { - format!( - "Invalid output detected in parameter value permutation {} of {} ('{}' \ - and '{}' contain the current and previous parameter values)", - permutation_no, - FUZZ_NUM_PERMUTATIONS, - current_param_values_file_path.display(), - previous_param_values_file_path.display(), - ) - }) - .unwrap_err()); - } - - std::mem::swap(&mut previous_events, &mut current_events); - } - - // `ProcessingTest::run()` already handled callbacks for us - host.callback_error_check() - .context("An error occured during a host callback")?; - - Ok(TestStatus::Success { details: None }) -} - -/// The test for `ProcessingTest::ParamSetWrongNamespace`. -pub fn test_param_set_wrong_namespace( - library: &PluginLibrary, - plugin_id: &str, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - let initial_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - - // We'll generate random parameter set events, but we'll change the namespace ID to something - // else. The plugin's parameter values should thus not update its parameter values. - const INCORRECT_NAMESPACE_ID: u16 = 0xb33f; - let param_fuzzer = ParamFuzzer::new(¶m_infos); - let mut random_param_set_events: Vec<_> = - param_fuzzer.randomize_params_at(&mut prng, 0).collect(); - for event in random_param_set_events.iter_mut() { - match event { - Event::ParamValue(event) => event.header.space_id = INCORRECT_NAMESPACE_ID, - event => panic!("Unexpected event {event:?}, this is a clap-validator bug"), - } - } - - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(BUFFER_SIZE); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)?.run_once( - ProcessConfig::default(), - move |process_data| { - *process_data.input_events.events.lock() = random_param_set_events; - - Ok(()) - }, - )?; - - // We'll check that the plugin has these sames values after reloading the state. These values - // are rounded to the tenth decimal to provide some leeway in the serialization and - // deserializatoin process. - let actual_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - - host.callback_error_check() - .context("An error occured during a host callback")?; - if actual_param_values == initial_param_values { - Ok(TestStatus::Success { details: None }) - } else { - Ok(TestStatus::Failed { - details: Some(format!( - "Sending events with type ID {CLAP_EVENT_PARAM_VALUE} (CLAP_EVENT_PARAM_VALUE) \ - and namespace ID {INCORRECT_NAMESPACE_ID:#x} to the plugin caused its parameter \ - values to change. This should not happen. The plugin may not be checking the \ - event's namespace ID." - )), - }) - } -} diff --git a/src/tests/plugin/processing.rs b/src/tests/plugin/processing.rs deleted file mode 100644 index 2888e6f..0000000 --- a/src/tests/plugin/processing.rs +++ /dev/null @@ -1,454 +0,0 @@ -//! Contains most of the boilerplate around testing audio processing. - -use std::sync::atomic::Ordering; - -use anyhow::{Context, Result}; - -use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; -use crate::plugin::ext::note_ports::NotePorts; -use crate::plugin::ext::Extension; -use crate::plugin::host::Host; -use crate::plugin::instance::process::{ - AudioBuffers, OutOfPlaceAudioBuffers, ProcessConfig, ProcessData, -}; -use crate::plugin::instance::Plugin; -use crate::plugin::library::PluginLibrary; -use crate::tests::rng::{new_prng, NoteGenerator}; -use crate::tests::TestStatus; - -/// A helper to handle the boilerplate that comes with testing a plugin's audio processing behavior. -pub struct ProcessingTest<'a> { - plugin: &'a Plugin<'a>, - audio_buffers: AudioBuffers<'a>, -} - -impl<'a> ProcessingTest<'a> { - /// Construct a new processing test using out-of-place processing. This allocates the CLAP audio - /// buffer structs needed for the test. Returns an error if the the inner vectors don't all have - /// the same length. - pub fn new_out_of_place( - plugin: &'a Plugin<'a>, - input_buffers: &'a mut [Vec>], - output_buffers: &'a mut [Vec>], - ) -> Result { - Ok(Self { - plugin, - audio_buffers: AudioBuffers::OutOfPlace(OutOfPlaceAudioBuffers::new( - input_buffers, - output_buffers, - )?), - }) - } - - /// Run the standard audio processing test for a still **deactivated** plugin. This calls the - /// process function `num_iters` times, and checks the output for consistency each time. - /// - /// The `Preprocess` closure is called before each processing cycle to allow the process data to be - /// modified for the next process cycle. - /// - /// Main-thread callbacks that were made to the plugin while the audio thread was active are - /// handled implicitly. - pub fn run( - &'a mut self, - num_iters: usize, - process_config: ProcessConfig, - mut preprocess: Preprocess, - ) -> Result<()> - where - Preprocess: FnMut(&mut ProcessData) -> Result<()> + Send, - { - self.plugin - .state - .requested_restart - .store(false, Ordering::SeqCst); - - let buffer_size = self.audio_buffers.len(); - let mut process_data = ProcessData::new(&mut self.audio_buffers, process_config); - - // If the plugin requests a restart in the middle of processing, then the plugin will be - // stopped, deactivated, reactivated, and started again. Because of that, we need to keep - // track of the number of processed iterations manually instead of using a for loop. - let mut iters_done = 0; - while iters_done < num_iters { - self.plugin - .activate(process_config.sample_rate, 1, buffer_size)?; - - self.plugin.on_audio_thread(|plugin| -> Result<()> { - plugin.start_processing()?; - - // This test can be repeated a couple of times - // NOTE: We intentionally do not disable denormals here - 'processing: while iters_done < num_iters { - iters_done += 1; - - preprocess(&mut process_data)?; - - // We'll check that the plugin hasn't modified the input buffers after the - // test - let original_input_buffers = process_data.buffers.inputs_ref().to_owned(); - - plugin - .process(&mut process_data) - .context("Error during audio processing")?; - - // When we add in-place processing this will need some slightly different checks - match process_data.buffers { - AudioBuffers::OutOfPlace(_) => check_out_of_place_output_consistency( - &process_data, - &original_input_buffers, - ), - } - .with_context(|| { - format!( - "Failed during processing cycle {} out of {}", - iters_done + 1, - num_iters - ) - })?; - - process_data.clear_events(); - process_data.advance_transport(buffer_size as u32); - - // Restart processing as necesasry - if plugin - .state() - .requested_restart - .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - log::trace!( - "Restarting the plugin during processing cycle {} out of {} after a \ - call to 'clap_host::request_restart()'", - iters_done + 1, - num_iters - ); - break 'processing; - } - } - - plugin.stop_processing(); - - Ok(()) - })?; - - self.plugin.deactivate(); - } - - // Handle callbacks the plugin may have made during deactivate - self.plugin.host().handle_callbacks_once(); - - Ok(()) - } - - /// Run the standard audio processing test for a still **deactivated** plugin. This is identical - /// to the [`run()`][Self::run()] function, except that it does exactly one processing cycle and - /// thus non-copy values can be moved into the closure. - /// - /// Main-thread callbacks that were made to the plugin while the audio thread was active are - /// handled implicitly. - pub fn run_once( - &'a mut self, - process_config: ProcessConfig, - preprocess: Preprocess, - ) -> Result<()> - where - Preprocess: FnOnce(&mut ProcessData) -> Result<()> + Send, - { - self.plugin - .state - .requested_restart - .store(false, Ordering::SeqCst); - - let buffer_size = self.audio_buffers.len(); - let mut process_data = ProcessData::new(&mut self.audio_buffers, process_config); - - self.plugin - .activate(process_config.sample_rate, 1, buffer_size)?; - - self.plugin.on_audio_thread(|plugin| -> Result<()> { - plugin.start_processing()?; - - preprocess(&mut process_data)?; - - // We'll check that the plugin hasn't modified the input buffers after the - // test - let original_input_buffers = process_data.buffers.inputs_ref().to_owned(); - - plugin - .process(&mut process_data) - .context("Error during audio processing")?; - - // When we add in-place processing this will need some slightly different checks - match process_data.buffers { - AudioBuffers::OutOfPlace(_) => { - check_out_of_place_output_consistency(&process_data, &original_input_buffers) - } - } - .context("Failed during processing")?; - - process_data.clear_events(); - process_data.advance_transport(buffer_size as u32); - - plugin.stop_processing(); - - Ok(()) - })?; - - self.plugin.deactivate(); - - // Handle callbacks the plugin may have made during deactivate - self.plugin.host().handle_callbacks_once(); - - Ok(()) - } -} - -/// The test for `ProcessingTest::ProcessAudioOutOfPlaceBasic`. -pub fn test_process_audio_out_of_place_basic( - library: &PluginLibrary, - plugin_id: &str, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - AudioPorts::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - // Handle callbacks the plugin may have made during init or these queries. The - // `ProcessingTest::run*` functions will implicitly handle all outstanding callbacks before they - // return. - host.handle_callbacks_once(); - - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(512); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)?.run( - 5, - ProcessConfig::default(), - |process_data| { - process_data.buffers.randomize(&mut prng); - - Ok(()) - }, - )?; - - // The `Host` contains built-in thread safety checks - host.callback_error_check() - .context("An error occured during a host callback")?; - Ok(TestStatus::Success { details: None }) -} - -/// The test for `ProcessingTest::ProcessNoteOutOfPlaceBasic`. This test is very similar to -/// `ProcessAudioOutOfPlaceBasic`, but it requires the `note-ports` extension, sends notes and/or -/// MIDI to the plugin, and doesn't require the `audio-ports` extension. -pub fn test_process_note_out_of_place_basic( - library: &PluginLibrary, - plugin_id: &str, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - // You can have note/MIDI-only plugins, so not having any audio ports is perfectly fine here - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let note_ports_config = match plugin.get_extension::() { - Some(note_ports) => note_ports - .config() - .context("Error while querying 'note-ports' IO configuration")?, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - NotePorts::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - if note_ports_config.inputs.is_empty() { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin implements the '{}' extension but it does not have any input note \ - ports.", - NotePorts::EXTENSION_ID.to_str().unwrap() - )), - }); - } - host.handle_callbacks_once(); - - // We'll fill the input event queue with (consistent) random CLAP note and/or MIDI - // events depending on what's supported by the plugin supports - let mut note_event_rng = NoteGenerator::new(note_ports_config); - - const BUFFER_SIZE: usize = 512; - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(BUFFER_SIZE); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)?.run( - 5, - ProcessConfig::default(), - |process_data| { - note_event_rng.fill_event_queue( - &mut prng, - &process_data.input_events, - BUFFER_SIZE as u32, - )?; - process_data.buffers.randomize(&mut prng); - - Ok(()) - }, - )?; - - host.callback_error_check() - .context("An error occured during a host callback")?; - Ok(TestStatus::Success { details: None }) -} - -/// The test for `ProcessingTest::ProcessNoteInconsistent`. This is the same test as -/// `ProcessAudioOutOfPlaceBasic`, but without requiring matched note on/off pairs and similar -/// invariants -pub fn test_process_note_inconsistent( - library: &PluginLibrary, - plugin_id: &str, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - plugin.init().context("Error during initialization")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let note_port_config = match plugin.get_extension::() { - Some(note_ports) => note_ports - .config() - .context("Error while querying 'note-ports' IO configuration")?, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - NotePorts::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - if note_port_config.inputs.is_empty() { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin implements the '{}' extension but it does not have any input note \ - ports.", - NotePorts::EXTENSION_ID.to_str().unwrap() - )), - }); - } - host.handle_callbacks_once(); - - // This RNG (Random Note Generator) allows generates mismatching events - let mut note_event_rng = NoteGenerator::new(note_port_config).with_inconsistent_events(); - - // TODO: Use in-place processing for this test - const BUFFER_SIZE: usize = 512; - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(BUFFER_SIZE); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)?.run( - 5, - ProcessConfig::default(), - |process_data| { - note_event_rng.fill_event_queue( - &mut prng, - &process_data.input_events, - BUFFER_SIZE as u32, - )?; - process_data.buffers.randomize(&mut prng); - - Ok(()) - }, - )?; - - host.callback_error_check() - .context("An error occured during a host callback")?; - Ok(TestStatus::Success { details: None }) -} - -/// The process for consistency. This verifies that the output buffer doesn't contain any NaN, -/// infinite, or denormal values, that the input buffers have not been modified by the plugin, and -/// that the output event queue is monotonically ordered. -fn check_out_of_place_output_consistency( - process_data: &ProcessData, - original_input_buffers: &[Vec>], -) -> Result<()> { - // The input buffer must not be overwritten during out of place processing, and the outputs - // should not contain any non-finite or denormal values - let num_samples = process_data.buffers.len() as u32; - let input_buffers = process_data.buffers.inputs_ref(); - let output_buffers = process_data.buffers.outputs_ref(); - if input_buffers != original_input_buffers { - anyhow::bail!( - "The plugin has overwritten the input buffers during out-of-place processing." - ); - } - for (port_idx, channel_slices) in output_buffers.iter().enumerate() { - for (channel_idx, channel_slice) in channel_slices.iter().enumerate() { - for (sample_idx, sample) in channel_slice.iter().enumerate() { - if !sample.is_finite() { - anyhow::bail!( - "The sample written to output port {port_idx}, channel {channel_idx}, and \ - sample index {sample_idx} is {sample:?}." - ); - } else if sample.is_subnormal() { - anyhow::bail!( - "The sample written to output port {port_idx}, channel {channel_idx}, and \ - sample index {sample_idx} is subnormal ({sample:?})." - ); - } - } - } - } - - // If the plugin output any events, then they should be in a monotonically increasing order - let mut last_event_time = 0; - for event in process_data.output_events.events.lock().iter() { - let event_time = event.header().time; - if event_time < last_event_time { - anyhow::bail!( - "The plugin output an event for sample {event_time} after it had previously \ - output an event for sample {last_event_time}." - ) - } - - last_event_time = event_time; - } - - if last_event_time >= num_samples { - anyhow::bail!( - "The plugin output an event for sample {last_event_time} but the audio buffer only \ - contains {num_samples} samples." - ) - } - - Ok(()) -} diff --git a/src/tests/plugin/state.rs b/src/tests/plugin/state.rs deleted file mode 100644 index 8410ea5..0000000 --- a/src/tests/plugin/state.rs +++ /dev/null @@ -1,645 +0,0 @@ -//! Tests surrounding state handling. - -use anyhow::{Context, Result}; -use clap_sys::id::clap_id; -use std::collections::BTreeMap; -use std::io::Write; - -use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; -use crate::plugin::ext::params::{ParamInfo, Params}; -use crate::plugin::ext::state::State; -use crate::plugin::ext::Extension; -use crate::plugin::host::Host; -use crate::plugin::instance::process::{Event, EventQueue, ProcessConfig}; -use crate::plugin::library::PluginLibrary; -use crate::tests::rng::{new_prng, ParamFuzzer}; -use crate::tests::{TestCase, TestStatus}; - -use super::processing::ProcessingTest; -use super::PluginTestCase; - -/// The file name we'll use to dump the expected state when a test fails. -const EXPECTED_STATE_FILE_NAME: &str = "state-expected"; -/// The file name we'll use to dump the actual state when a test fails. -const ACTUAL_STATE_FILE_NAME: &str = "state-actual"; - -/// The test for `PluginTestCase::StateInvalid`. -pub fn test_state_invalid(library: &PluginLibrary, plugin_id: &str) -> Result { - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - - plugin.init().context("Error during initialization")?; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - match state.load(&[]) { - Ok(_) => anyhow::bail!( - "The plugin returned true when 'clap_plugin_state::load()' was called when an empty \ - state, this is likely a bug." - ), - Err(_) => { - host.handle_callbacks_once(); - host.callback_error_check() - .context("An error occured during a host callback")?; - - Ok(TestStatus::Success { details: None }) - } - } -} - -/// The test for `PluginTestCase::StateReproducibilityNullCookies`. See the description of this test -/// for a detailed explanation, but we essentially check if saving a loaded state results in the -/// same state file, and whether a plugin's parameters are the same after loading the state. -/// -/// The `zero_out_cookies` parameter offers an alternative on this test that sends parameter change -/// events with all cookies set to null pointers. The plugin should behave identically when this -/// happens. -pub fn test_state_reproducibility_null_cookies( - library: &PluginLibrary, - plugin_id: &str, - zero_out_cookies: bool, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - - // We'll drop and reinitialize the plugin later - let (expected_state, expected_param_values) = { - plugin.init().context("Error during initialization")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - // We can't compare the values from these events direclty as the plugin - // may round the values during the parameter set - let param_fuzzer = ParamFuzzer::new(¶m_infos); - let mut random_param_set_events: Vec<_> = - param_fuzzer.randomize_params_at(&mut prng, 0).collect(); - - // This is a variation on the test that checks whether the plugin handles null - // pointer cookies correctly - if zero_out_cookies { - for event in &mut random_param_set_events { - match event { - Event::ParamValue(event) => { - event.cookie = std::ptr::null_mut(); - } - event => { - panic!("Unexpected event {event:?}, this is a clap-validator bug") - } - } - } - } - - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(512); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)? - .run_once(ProcessConfig::default(), move |process_data| { - *process_data.input_events.events.lock() = random_param_set_events; - - Ok(()) - })?; - - // We'll check that the plugin has these sames values after reloading the state. These - // values are rounded to the tenth decimal to provide some leeway in the serialization and - // deserializatoin process. - let expected_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - - let expected_state = state.save()?; - host.handle_callbacks_once(); - - (expected_state, expected_param_values) - }; - - // Now we'll recreate the plugin instance, load the state, and check whether the values are - // consistent and whether saving the state again results in an idential state file. This ends up - // being a bit of a lengthy test case because of this multiple initialization. Before - // continueing, we'll make sure the first plugin instance no longer exists. - drop(plugin); - - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance a second time")?; - plugin - .init() - .context("Error while initializing the second plugin instance")?; - - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - // I sure hope that no plugin will ever hit this - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }); - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin's second instance does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap() - )), - }) - } - }; - host.handle_callbacks_once(); - - state.load(&expected_state)?; - host.handle_callbacks_once(); - - let actual_param_values: BTreeMap = expected_param_values - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - if actual_param_values != expected_param_values { - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - // To avoid flooding the output too much, we'll print only the different values - anyhow::bail!( - "After reloading the state, the plugin's parameter values do not match the old values \ - when queried through 'clap_plugin_params::get()'. The mismatching values are {}.", - format_mismatching_values(actual_param_values, &expected_param_values, ¶m_infos) - ); - } - - // Now for the monent of truth - let actual_state = state.save()?; - host.handle_callbacks_once(); - - host.callback_error_check() - .context("An error occured during a host callback")?; - if actual_state == expected_state { - Ok(TestStatus::Success { details: None }) - } else { - let (expected_state_file_path, mut expected_state_file) = - PluginTestCase::StateReproducibilityBasic - .temporary_file(plugin_id, EXPECTED_STATE_FILE_NAME)?; - let (actual_state_file_path, mut actual_state_file) = - PluginTestCase::StateReproducibilityBasic - .temporary_file(plugin_id, ACTUAL_STATE_FILE_NAME)?; - - expected_state_file.write_all(&expected_state)?; - actual_state_file.write_all(&actual_state)?; - - anyhow::bail!( - "Re-saving the loaded state resulted in a different state file. Expected: '{}'. \ - Actual: '{}'.", - expected_state_file_path.display(), - actual_state_file_path.display(), - ) - } -} - -/// The test for `PluginTestCase::StateReproducibilityFlush`. -pub fn test_state_reproducibility_flush( - library: &PluginLibrary, - plugin_id: &str, -) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - - // We'll drop and reinitialize the plugin later. This first pass sets the values using the flush - // function, and the second pass we'll compare this to uses the process function. We'll reuse - // the parameter set events, but the cookies need to be updated first or they'll point to old - // data. - let (expected_state, old_random_param_set_events, expected_param_values) = { - plugin.init().context("Error during initialization")?; - - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - // Make sure the flush does _something_. If nothing changes, then the plugin has not - // implemented flush. - let initial_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - - // The same param set events will be passed to the flush function in this pass and to the - // process fuction in the second pass - let param_fuzzer = ParamFuzzer::new(¶m_infos); - let random_param_set_events: Vec<_> = - param_fuzzer.randomize_params_at(&mut prng, 0).collect(); - - let input_events = EventQueue::new_input(); - *input_events.events.lock() = random_param_set_events.clone(); - let output_events = EventQueue::new_output(); - params.flush(&input_events, &output_events); - host.handle_callbacks_once(); - - // We'll compare against these values in that second pass - let expected_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - let expected_state = state.save()?; - host.handle_callbacks_once(); - - // Plugins with no parameters at all should of course not trigger this error - if expected_param_values == initial_param_values && !param_infos.is_empty() { - anyhow::bail!( - "'clap_plugin_params::flush()' has been called with random parameter values, but \ - the plugin's reported parameter values have not changed." - ) - } - - ( - expected_state, - random_param_set_events, - expected_param_values, - ) - }; - - // This works the same as the basic state reproducibility test, except that we load the values - // using the process funciton instead of loading the state - drop(plugin); - - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance a second time")?; - plugin - .init() - .context("Error while initializing the second plugin instance")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - // I sure hope that no plugin will eer hit this - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin's second instance does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap() - )), - }); - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin's second instance does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap() - )), - }); - } - }; - host.handle_callbacks_once(); - - // NOTE: We can reuse random parameter set events, except that the cookie pointers may be - // different if the plugin uses those. So we need to update these cookies first. - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - let mut new_random_param_set_events = old_random_param_set_events; - for event in new_random_param_set_events.iter_mut() { - match event { - Event::ParamValue(event) => { - event.cookie = param_infos - .get(&event.param_id) - .with_context(|| { - format!( - "Expected the plugin to have a parameter with ID {}, but the \ - parameter is missing", - event.param_id, - ) - })? - .cookie; - } - event => panic!("Unexpected event {event:?}, this is a clap-validator bug"), - } - } - - // In theprevious pass we used flush, and here we use the process funciton - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(512); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)?.run_once( - ProcessConfig::default(), - move |process_data| { - *process_data.input_events.events.lock() = new_random_param_set_events; - - Ok(()) - }, - )?; - - let actual_param_values: BTreeMap = expected_param_values - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - if actual_param_values != expected_param_values { - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - anyhow::bail!( - "Setting the same parameter values through 'clap_plugin_params::flush()' and through \ - the process funciton results in different reported values when queried through \ - 'clap_plugin_params::get_value()'. The mismatching values are {}.", - format_mismatching_values(actual_param_values, &expected_param_values, ¶m_infos) - ); - } - - let actual_state = state.save()?; - host.handle_callbacks_once(); - - host.callback_error_check() - .context("An error occured during a host callback")?; - if actual_state == expected_state { - Ok(TestStatus::Success { details: None }) - } else { - let (expected_state_file_path, mut expected_state_file) = - PluginTestCase::StateReproducibilityFlush - .temporary_file(plugin_id, EXPECTED_STATE_FILE_NAME)?; - let (actual_state_file_path, mut actual_state_file) = - PluginTestCase::StateReproducibilityFlush - .temporary_file(plugin_id, ACTUAL_STATE_FILE_NAME)?; - - expected_state_file.write_all(&expected_state)?; - actual_state_file.write_all(&actual_state)?; - - anyhow::bail!( - "Sending the same parameter values to two different instances of the plugin resulted \ - in different state files. Expected: '{}'. Actual: '{}'.", - expected_state_file_path.display(), - actual_state_file_path.display(), - ) - } -} - -/// The test for `PluginTestCase::StateBufferedStreams`. -pub fn test_state_buffered_streams(library: &PluginLibrary, plugin_id: &str) -> Result { - let mut prng = new_prng(); - - let host = Host::new(); - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance")?; - - let (expected_state, expected_param_values) = { - plugin.init().context("Error during initialization")?; - - let audio_ports_config = match plugin.get_extension::() { - Some(audio_ports) => audio_ports - .config() - .context("Error while querying 'audio-ports' IO configuration")?, - None => AudioPortConfig::default(), - }; - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap(), - )), - }) - } - }; - host.handle_callbacks_once(); - - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - let param_fuzzer = ParamFuzzer::new(¶m_infos); - let random_param_set_events: Vec<_> = - param_fuzzer.randomize_params_at(&mut prng, 0).collect(); - let (mut input_buffers, mut output_buffers) = audio_ports_config.create_buffers(512); - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)? - .run_once(ProcessConfig::default(), move |process_data| { - *process_data.input_events.events.lock() = random_param_set_events; - - Ok(()) - })?; - - let expected_param_values: BTreeMap = param_infos - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - - // This state file is saved without buffered writes. It's expected that the plugin - // implementsq this correctly, so we can check if it handles buffered streams correctly by - // treating this as the ground truth. - let expected_stae = state.save()?; - host.handle_callbacks_once(); - - (expected_stae, expected_param_values) - }; - - // Now we'll recreate the plugin instance, load the state using buffered reads, check the - // parameter values, save it again using buffered writes, and then check whether the fir. - drop(plugin); - - let plugin = library - .create_plugin(plugin_id, host.clone()) - .context("Could not create the plugin instance a second time")?; - plugin - .init() - .context("Error while initializing the second plugin instance")?; - let params = match plugin.get_extension::() { - Some(params) => params, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin's second instance does not implement the '{}' extension.", - Params::EXTENSION_ID.to_str().unwrap() - )), - }); - } - }; - let state = match plugin.get_extension::() { - Some(state) => state, - None => { - return Ok(TestStatus::Skipped { - details: Some(format!( - "The plugin's second instance does not implement the '{}' extension.", - State::EXTENSION_ID.to_str().unwrap() - )), - }); - } - }; - host.handle_callbacks_once(); - - // This is a buffered load that only loads 17 bytes at a time. Why 17? Because. - const BUFFERED_LOAD_MAX_BYTES: usize = 17; - state.load_buffered(&expected_state, BUFFERED_LOAD_MAX_BYTES)?; - host.handle_callbacks_once(); - - let actual_param_values: BTreeMap = expected_param_values - .keys() - .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) - .collect::>>()?; - if actual_param_values != expected_param_values { - let param_infos = params - .info() - .context("Failure while fetching the plugin's parameters")?; - - // To avoid flooding the output too much, we'll print only the different - // values - anyhow::bail!( - "After reloading the state by allowing the plugin to read at most \ - {BUFFERED_LOAD_MAX_BYTES} bytes at a time, the plugin's parameter values do not \ - match the old values when queried through 'clap_plugin_params::get()'. The \ - mismatching values are {}.", - format_mismatching_values(actual_param_values, &expected_param_values, ¶m_infos) - ); - } - - // Because we're mean, we'll use a different prime number for the saving - const BUFFERED_SAVE_MAX_BYTES: usize = 23; - let actual_state = state.save_buffered(BUFFERED_SAVE_MAX_BYTES)?; - host.handle_callbacks_once(); - - host.callback_error_check() - .context("An error occured during a host callback")?; - if actual_state == expected_state { - Ok(TestStatus::Success { details: None }) - } else { - let (expected_state_file_path, mut expected_state_file) = - PluginTestCase::StateBufferedStreams - .temporary_file(plugin_id, EXPECTED_STATE_FILE_NAME)?; - let (actual_state_file_path, mut actual_state_file) = PluginTestCase::StateBufferedStreams - .temporary_file(plugin_id, ACTUAL_STATE_FILE_NAME)?; - - expected_state_file.write_all(&expected_state)?; - actual_state_file.write_all(&actual_state)?; - - anyhow::bail!( - "Re-saving the loaded state resulted in a different state file. The original state \ - file being compared to was written unbuffered, reloaded by allowing the plugin to \ - read only {BUFFERED_LOAD_MAX_BYTES} bytes at a time, and then written again by \ - allowing the plugin to write only {BUFFERED_SAVE_MAX_BYTES} bytes at a time. \ - Expected: '{}'. Actual: '{}'.", - expected_state_file_path.display(), - actual_state_file_path.display(), - ) - } -} - -/// Build a string containing all different values between two sets of values. -/// -/// # Panics -/// -/// If the parameters in `actual_param_values` don't have corresponding entries in -/// `expected_param_values` and `param_infos`. -fn format_mismatching_values( - actual_param_values: BTreeMap, - expected_param_values: &BTreeMap, - param_infos: &ParamInfo, -) -> String { - actual_param_values - .into_iter() - .filter_map(|(param_id, actual_value)| { - let expected_value = expected_param_values[¶m_id]; - if actual_value == expected_value { - None - } else { - let param_name = ¶m_infos[¶m_id].name; - Some(format!( - "parameter {param_id} ('{param_name}'), expected {expected_value:?}, actual \ - {actual_value:?}" - )) - } - }) - .collect::>() - .join(", ") -} diff --git a/src/tests/plugin_instance.rs b/src/tests/plugin_instance.rs new file mode 100644 index 0000000..6e07d0b --- /dev/null +++ b/src/tests/plugin_instance.rs @@ -0,0 +1,312 @@ +//! Tests for individual plugin instances. + +use crate::cli::tracing::{Span, record}; +use crate::plugin::library::PluginLibrary; +use crate::tests::TestStatus; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +mod descriptor; +mod layout; +mod params; +mod processing; +mod state; +mod transport; + +/// The tests for individual CLAP plugins. See the module's heading for more information, and the +/// `description` function below for a description of each test case. +#[derive( + strum_macros::Display, + strum_macros::EnumString, + strum_macros::EnumIter, + strum_macros::IntoStaticStr, + Serialize, + Deserialize, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum PluginInstanceTestCase { + DescriptorConsistency, + FeaturesCategories, + FeaturesDuplicates, + ProcessAudioBasicOutOfPlace, + ProcessAudioBasicInPlace, + ProcessAudioDoubleOutOfPlace, + ProcessAudioDoubleInPlace, + ProcessAudioDenormals, + ProcessSleepConstantMask, + ProcessSleepProcessStatus, + ProcessNoteOutOfPlaceBasic, + ProcessNoteInconsistent, + ProcessNoteWildcard, + ProcessVaryingSampleRates, + ProcessVaryingBlockSizes, + ProcessRandomBlockSizes, + ProcessResetReactivate, + LayoutAudioPortsActivation, + LayoutAudioPortsConfig, + LayoutConfigurableAudioPorts, + ParamSetEvents, + ParamSetNoCookies, + ParamSetWrongNamespace, + ParamFuzzBasic, + ParamFuzzBounds, + ParamFuzzSampleAccurate, + ParamFuzzModulation, + ParamConversions, + ParamDefaultValues, + StateInvalidEmpty, + StateInvalidRandom, + StateReproducibilityBasic, + StateReproducibilityBinary, + StateReproducibilityBuffered, + TransportNull, + TransportFuzz, + TransportFuzzSampleAccurate, +} + +impl PluginInstanceTestCase { + pub fn description(&self) -> String { + match self { + Self::DescriptorConsistency => String::from( + "The plugin descriptor returned from the plugin factory and the plugin descriptor stored on the \ + 'clap_plugin' object should be equivalent.", + ), + Self::FeaturesCategories => { + String::from("The plugin needs to have at least one of the main CLAP category features.") + } + Self::FeaturesDuplicates => String::from("The plugin's features array should not contain any duplicates."), + Self::ProcessAudioBasicOutOfPlace => String::from( + "Processes random audio through the plugin with its default parameter values and tests whether the \ + output does not contain any non-finite or subnormal values. Uses out-of-place audio processing.", + ), + Self::ProcessAudioBasicInPlace => String::from( + "Processes random audio through the plugin with its default parameter values and tests whether the \ + output does not contain any non-finite or subnormal values. Uses in-place audio processing for buses \ + that support it.", + ), + Self::ProcessAudioDoubleOutOfPlace => format!( + "Same as '{}', but uses 64-bit floating point audio buffers instead of 32-bit ones for ports that \ + support it.", + Self::ProcessAudioBasicOutOfPlace, + ), + Self::ProcessAudioDoubleInPlace => format!( + "Same as '{}', but uses 64-bit floating point audio buffers instead of 32-bit ones for ports that \ + support it.", + Self::ProcessAudioBasicInPlace, + ), + Self::ProcessAudioDenormals => String::from( + "Processes random audio through the plugin with its default parameter values two times: without and \ + with denormals as the input. Emits a warning if processing denormals causes a significant slowdown.", + ), + Self::LayoutAudioPortsActivation => format!( + "Same as '{}', but this time it toggles the activation state of audio ports on and off via the \ + 'audio-ports-activation' extension.", + Self::ProcessAudioBasicOutOfPlace, + ), + Self::LayoutConfigurableAudioPorts => format!( + "Same as '{}', but this time it tries random configurations exposed via the \ + 'configurable-audio-ports' extension.", + Self::ProcessAudioBasicOutOfPlace, + ), + Self::LayoutAudioPortsConfig => format!( + "Same as '{}', but this time it tries all available port configurations exposed via the \ + 'audio-ports-config' extension.", + Self::ProcessAudioBasicInPlace, + ), + Self::ProcessSleepConstantMask => String::from( + "Processes random audio through the plugin with its default parameter values while setting the \ + constant mask on silent blocks, and tests whether the output does not contain any non-finite or \ + subnormal values and that the plugin sets the constant mask correctly", + ), + Self::ProcessSleepProcessStatus => String::from( + "Processes random audio through the plugin with its default parameter values while checking if the \ + output is consistent with the returned process status, and tests whether the output does not contain \ + any non-finite or subnormal values and that the plugin sets the process status correctly", + ), + Self::ProcessNoteOutOfPlaceBasic => String::from( + "Sends audio and random note and MIDI events to the plugin with its default parameter values and \ + tests the output for consistency. Uses out-of-place audio processing.", + ), + Self::ProcessNoteInconsistent => String::from( + "Sends intentionally inconsistent and mismatching note and MIDI events to the plugin with its default \ + parameter values and tests the output for consistency. Uses out-of-place audio processing.", + ), + Self::ProcessNoteWildcard => format!( + "Same as {}, but this time some note events have their note ID, port index, channel, or key set to \ + -1, which means they can match multiple notes at the same time. This tests whether the plugin can \ + handle such wildcard events without crashing or producing invalid output. Uses out-of-place audio \ + processing.", + Self::ProcessNoteOutOfPlaceBasic + ), + Self::ProcessVaryingSampleRates => String::from( + "Processes random audio and random note events through the plugin with its default parameter values \ + while trying different sample rates ranging from 1kHz to 768kHz, including fractional rates, and \ + tests whether the output does not contain any non-finite or subnormal values. Uses out-of-place \ + audio processing.", + ), + Self::ProcessVaryingBlockSizes => String::from( + "Processes random audio and random note events through the plugin with its default parameter values \ + while trying different maximum block sizes ranging from 1 to 16k, including non-power-of-two ones, \ + and tests whether the output does not contain any non-finite or subnormal values. Uses out-of-place \ + audio processing.", + ), + Self::ProcessRandomBlockSizes => String::from( + "Processes random audio and random note events through the plugin with maximum block size of 2048 \ + while randomizing block sizes for each process call, and tests whether the output does not contain \ + any non-finite or subnormal values. Uses out-of-place audio processing.", + ), + Self::ProcessResetReactivate => String::from( + "Asserts that resetting the plugin via 'clap_plugin::reset()' and via re-activation does not cause \ + any crashes, and that the plugin still produces valid (non-NaN and non-infinite) output", + ), + Self::ParamConversions => String::from( + "Asserts that value to string and string to value conversions are supported for either all or none of \ + the plugin's parameters, and that conversions between values and strings roundtrip consistently.", + ), + Self::ParamSetEvents => String::from( + "Asserts that the resulting parameter values after a flush are the same as if the parameter changes \ + were sent via a process call.", + ), + Self::ParamSetNoCookies => format!( + "Same as '{}', but this time the parameter change events are sent with null cookies. The plugin \ + should behave identically to when the cookies are set to non-null values.", + Self::ParamSetEvents, + ), + Self::ParamFuzzBasic => format!( + "Generates {} sets of random parameter values, sets those on the plugin, and has the plugin process \ + {} buffers of random audio and note events. The plugin passes the test if it doesn't produce any \ + infinite or NaN values, and doesn't crash.", + params::FUZZ_NUM_PERMUTATIONS, + params::FUZZ_RUNS_PER_PERMUTATION + ), + Self::ParamFuzzBounds => format!( + "The exact same test as '{}', but this time the parameter values are snapped to the minimum and \ + maximum values.", + Self::ParamFuzzBasic + ), + Self::ParamFuzzSampleAccurate => String::from( + "Sets parameter values in a sample-accurate fashion while processing audio, generating them at fixed \ + intervals (10, 100, 1000 samples). The plugin passes the test if it doesn't produce any infinite or \ + NaN values, and doesn't crash.", + ), + Self::ParamFuzzModulation => String::from( + "Sends parameter change events, including monophonic modulation and polyphonic automation/modulation \ + events at random irregular unsynchronized intervals, and have the plugin process them. The plugin \ + passes the test if it doesn't produce any infinite or NaN values, and doesn't crash.", + ), + Self::ParamSetWrongNamespace => String::from( + "Sends events to the plugin with the 'CLAP_EVENT_PARAM_VALUE' event type but with a mismatching \ + namespace ID. Asserts that the plugin's parameter values don't change.", + ), + Self::ParamDefaultValues => String::from( + "Asserts that the values for all parameters are set correctly to their default values when the plugin \ + is initialized.", + ), + Self::StateInvalidEmpty => String::from( + "The plugin should return false when 'clap_plugin_state::load()' is called with an empty state.", + ), + Self::StateInvalidRandom => String::from( + "Loads 3x1MB chunks of random bytes via 'clap_plugin_state::load()' and asserts that the plugin \ + doesn't crash.", + ), + Self::StateReproducibilityBasic => String::from( + "Randomizes a plugin's parameters, saves its state, recreates the plugin instance, reloads the state, \ + and then checks whether the parameter values are the same and whether saving the state once more \ + results in the same parameters as before. The parameter values are updated using the process \ + function.", + ), + Self::StateReproducibilityBuffered => format!( + "Performs the same parameter reproducibility check as in '{}', but this time the plugin is only \ + allowed to read a small prime number of bytes at a time when reloading and resaving the state.", + Self::StateReproducibilityBasic + ), + Self::StateReproducibilityBinary => format!( + "Performs the same parameter reproducibility check as in '{}', but also checks that the saved state \ + data is exactly the same byte for byte. This means that the plugin needs to save the state in a \ + completely deterministic way, without any non-determinism coming from things like uninitialized \ + memory or random bytes.", + Self::StateReproducibilityBasic + ), + Self::TransportNull => String::from( + "Performs audio processing with a 'null' transport pointer, simulating a free-running transport \ + state. The plugin passes the test if it doesn't produce any infinite or NaN values, and doesn't \ + crash.", + ), + Self::TransportFuzz => String::from( + "Performs audio processing while randomly changing the transport state on every block. The plugin \ + passes the test if it doesn't produce any infinite or NaN values, and doesn't crash.", + ), + Self::TransportFuzzSampleAccurate => format!( + "Same as '{}', but this time the test sends 'clap_event_transport' events in sample-accurate fashion \ + while processing audio, generating them at fixed intervals (1, 100, 1000 samples). The plugin passes \ + the test if it doesn't produce any infinite or NaN values, and doesn't crash.", + Self::TransportFuzz + ), + } + } + + pub fn run(&self, library_path: &Path, plugin_id: &str) -> Result { + let _span = Span::begin( + self.into(), + record! { + library_path: library_path.display().to_string(), + plugin_id: plugin_id + }, + ); + + // SAFETY: This is called on the main thread. + let library = &PluginLibrary::load(library_path) + .with_context(|| format!("Could not load '{}'", library_path.display()))?; + + match self { + Self::DescriptorConsistency => descriptor::test_consistency(library, plugin_id), + Self::FeaturesCategories => descriptor::test_features_categories(library, plugin_id), + Self::FeaturesDuplicates => descriptor::test_features_duplicates(library, plugin_id), + Self::LayoutAudioPortsActivation => layout::test_layout_audio_ports_activation(library, plugin_id), + Self::LayoutAudioPortsConfig => layout::test_layout_audio_ports_config(library, plugin_id), + Self::LayoutConfigurableAudioPorts => layout::test_layout_configurable_audio_ports(library, plugin_id), + Self::ProcessAudioBasicOutOfPlace => processing::test_process_audio_basic(library, plugin_id, false), + Self::ProcessAudioBasicInPlace => processing::test_process_audio_basic(library, plugin_id, true), + Self::ProcessAudioDoubleOutOfPlace => processing::test_process_audio_double(library, plugin_id, false), + Self::ProcessAudioDoubleInPlace => processing::test_process_audio_double(library, plugin_id, true), + Self::ProcessAudioDenormals => processing::test_process_audio_denormals(library, plugin_id), + Self::ProcessSleepConstantMask => processing::test_process_sleep_constant_mask(library, plugin_id), + Self::ProcessSleepProcessStatus => processing::test_process_sleep_process_status(library, plugin_id), + Self::ProcessNoteOutOfPlaceBasic => { + processing::test_process_note_out_of_place(library, plugin_id, false, false) + } + Self::ProcessNoteInconsistent => { + processing::test_process_note_out_of_place(library, plugin_id, true, false) + } + Self::ProcessNoteWildcard => processing::test_process_note_out_of_place(library, plugin_id, false, true), + Self::ProcessVaryingSampleRates => processing::test_process_varying_sample_rates(library, plugin_id), + Self::ProcessVaryingBlockSizes => processing::test_process_varying_block_sizes(library, plugin_id), + Self::ProcessRandomBlockSizes => processing::test_process_random_block_sizes(library, plugin_id), + Self::ProcessResetReactivate => processing::test_process_reset_reactivate(library, plugin_id), + Self::ParamConversions => params::test_param_conversions(library, plugin_id), + Self::ParamSetEvents => params::test_param_set_events(library, plugin_id, false), + Self::ParamSetNoCookies => params::test_param_set_events(library, plugin_id, true), + Self::ParamSetWrongNamespace => params::test_param_set_wrong_namespace(library, plugin_id), + Self::ParamFuzzBasic => params::test_param_fuzz_basic(library, plugin_id, false), + Self::ParamFuzzBounds => params::test_param_fuzz_basic(library, plugin_id, true), + Self::ParamFuzzSampleAccurate => params::test_param_fuzz_sample_accurate(library, plugin_id), + Self::ParamFuzzModulation => params::test_param_fuzz_modulation(library, plugin_id), + Self::ParamDefaultValues => params::test_param_default_values(library, plugin_id), + Self::StateInvalidEmpty => state::test_state_invalid_empty(library, plugin_id), + Self::StateInvalidRandom => state::test_state_invalid_random(library, plugin_id), + Self::StateReproducibilityBasic => state::test_state_reproducibility(library, plugin_id, false, false), + Self::StateReproducibilityBuffered => state::test_state_reproducibility(library, plugin_id, true, false), + Self::StateReproducibilityBinary => state::test_state_reproducibility(library, plugin_id, false, true), + Self::TransportNull => transport::test_transport_null(library, plugin_id), + Self::TransportFuzz => transport::test_transport_fuzz(library, plugin_id), + Self::TransportFuzzSampleAccurate => transport::test_transport_fuzz_sample_accurate(library, plugin_id), + } + } +} diff --git a/src/tests/plugin/descriptor.rs b/src/tests/plugin_instance/descriptor.rs similarity index 82% rename from src/tests/plugin/descriptor.rs rename to src/tests/plugin_instance/descriptor.rs index 3153a3a..6b0d428 100644 --- a/src/tests/plugin/descriptor.rs +++ b/src/tests/plugin_instance/descriptor.rs @@ -1,15 +1,10 @@ //! Tests surrounding plugin features. -use anyhow::{Context, Result}; -use clap_sys::plugin_features::{ - CLAP_PLUGIN_FEATURE_ANALYZER, CLAP_PLUGIN_FEATURE_AUDIO_EFFECT, CLAP_PLUGIN_FEATURE_INSTRUMENT, - CLAP_PLUGIN_FEATURE_NOTE_DETECTOR, CLAP_PLUGIN_FEATURE_NOTE_EFFECT, -}; -use std::collections::HashSet; - -use crate::plugin::host::Host; use crate::plugin::library::PluginLibrary; use crate::tests::TestStatus; +use anyhow::{Context, Result}; +use clap_sys::plugin_features::*; +use std::collections::HashSet; /// Verifies that the descriptor stored in the factory and the descriptor stored on the plugin /// object are equivalent. @@ -24,11 +19,10 @@ pub fn test_consistency(library: &PluginLibrary, plugin_id: &str) -> Result Result Res || feature == analyzer_feature }); - if has_main_category { - Ok(TestStatus::Success { details: None }) - } else { + if !has_main_category { anyhow::bail!( - "The plugin needs to have at least one of thw following plugin category features: \ + "The plugin needs to have at least one of the following plugin category features: \ \"{instrument_feature}\", \"{audio_effect_feature}\", \"{note_effect_feature}\", or \ \"{analyzer_feature}\"." - ) + ); } + + Ok(TestStatus::Success { details: None }) } /// Confirm that the plugin does not have any duplicate features. @@ -102,12 +96,12 @@ fn plugin_features(library: &PluginLibrary, plugin_id: &str) -> Result Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports = match plugin.get_extension::() { + Some(audio_ports) => audio_ports, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let audio_ports_config_info = plugin.get_extension::(); + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports_config) => audio_ports_config, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports-config' extension.", + )), + }); + } + }; + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + for config_audio_ports_config in audio_ports_config + .enumerate() + .context("Could not enumerate audio port configurations")? + { + let _span = Span::begin( + "Config", + record! { id: config_audio_ports_config.id, name: &config_audio_ports_config.name }, + ); + + audio_ports_config + .select(config_audio_ports_config.id) + .with_context(|| { + format!( + "Could not select audio port configuration '{}' ({})", + config_audio_ports_config.name, config_audio_ports_config.id, + ) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + let config_audio_ports = audio_ports.config().with_context(|| { + format!( + "Error while querying 'audio-ports' IO configuration with layout '{}' ({})", + config_audio_ports_config.name, config_audio_ports_config.id, + ) + })?; + + // Check that the audio-ports-config info matches the actual audio-ports config + { + let main_input_channels = config_audio_ports + .inputs + .first() + .filter(|x| x.is_main) + .map(|x| x.channel_count); + + let main_output_channels = config_audio_ports + .outputs + .first() + .filter(|x| x.is_main) + .map(|x| x.channel_count); + + let main_input_port_type = config_audio_ports + .inputs + .first() + .filter(|x| x.is_main) + .map(|x| &x.port_type); + + let main_output_port_type = config_audio_ports + .outputs + .first() + .filter(|x| x.is_main) + .map(|x| &x.port_type); + + anyhow::ensure!( + config_audio_ports.inputs.len() as u32 == config_audio_ports_config.input_port_count, + "The number of input audio ports for configuration '{}' ({}) does not match the number reported by \ + 'audio-ports' ({})", + config_audio_ports_config.name, + config_audio_ports_config.input_port_count, + config_audio_ports.inputs.len() as u32, + ); + + anyhow::ensure!( + config_audio_ports.outputs.len() as u32 == config_audio_ports_config.output_port_count, + "The number of output audio ports for configuration '{}' ({}) does not match the number reported by \ + 'audio-ports' ({})", + config_audio_ports_config.name, + config_audio_ports_config.output_port_count, + config_audio_ports.outputs.len() as u32, + ); + + anyhow::ensure!( + main_input_port_type == config_audio_ports_config.main_input_port_type.as_ref(), + "The main input port type for the '{}' configuration info ({:?}) does not match the type reported by \ + 'audio-ports' ({:?})", + config_audio_ports_config.name, + config_audio_ports_config.main_input_port_type, + main_input_port_type, + ); + + anyhow::ensure!( + main_output_port_type == config_audio_ports_config.main_output_port_type.as_ref(), + "The main output port type for the '{}' configuration info ({:?}) does not match the type reported by \ + 'audio-ports' ({:?})", + config_audio_ports_config.name, + config_audio_ports_config.main_output_port_type, + main_output_port_type, + ); + + match (main_input_channels, config_audio_ports_config.main_input_channel_count) { + (None, None) => {} + (Some(a), Some(b)) => anyhow::ensure!( + a == b, + "The number of channels in the main input port for the '{}' configuration info ({}) does not \ + match the number reported by 'audio-ports' ({})", + config_audio_ports_config.name, + b, + a, + ), + (None, Some(_)) => { + anyhow::bail!( + "The configuration '{}' reports that a main input port exists, but 'audio-ports' does not.", + config_audio_ports_config.name, + ) + } + (Some(_), None) => anyhow::bail!( + "The configuration '{}' reports that main input port does not exist, but according to \ + 'audio-ports' it does.", + config_audio_ports_config.name, + ), + } + + match ( + main_output_channels, + config_audio_ports_config.main_output_channel_count, + ) { + (None, None) => {} + (Some(a), Some(b)) => anyhow::ensure!( + a == b, + "The number of channels in the main output port for the '{}' configuration info ({}) does not \ + match the number reported by 'audio-ports' ({})", + config_audio_ports_config.name, + b, + a, + ), + (None, Some(_)) => { + anyhow::bail!( + "The configuration '{}' reports that a main output port exists, but 'audio-ports' does not.", + config_audio_ports_config.name, + ) + } + (Some(_), None) => anyhow::bail!( + "The configuration '{}' reports that main output port does not exist, but according to \ + 'audio-ports' it does.", + config_audio_ports_config.name, + ), + } + } + + // Check that the audio-ports-config-info matches the current config + if let Some(audio_ports_config_info) = &audio_ports_config_info { + anyhow::ensure!( + audio_ports_config_info.current() == config_audio_ports_config.id, + "The current configuration ID reported by 'audio-ports-config-info' ({}) does not match the last \ + selected configuration ID ({})", + audio_ports_config_info.current(), + config_audio_ports_config.id, + ); + + for index in 0..config_audio_ports_config.input_port_count { + let extra_info = audio_ports_config_info + .get(config_audio_ports_config.id, true, index) + .with_context(|| { + format!( + "Could not get info for input port {} of configuration '{}' ({}) from \ + 'audio-ports-config-info'", + index, config_audio_ports_config.name, config_audio_ports_config.id, + ) + })?; + + anyhow::ensure!( + extra_info == config_audio_ports.inputs[index as usize], + "Mismatch between info queried via 'audio-ports-config-info' and 'audio-ports' for input port {} \ + of configuration '{}' ({})", + index, + config_audio_ports_config.name, + config_audio_ports_config.id, + ) + } + + for index in 0..config_audio_ports_config.output_port_count { + let extra_info = audio_ports_config_info + .get(config_audio_ports_config.id, false, index) + .with_context(|| { + format!( + "Could not get info for output port {} of configuration '{}' ({}) from \ + 'audio-ports-config-info'", + index, config_audio_ports_config.name, config_audio_ports_config.id, + ) + })?; + + anyhow::ensure!( + extra_info == config_audio_ports.outputs[index as usize], + "Mismatch between info queried via 'audio-ports-config-info' and 'audio-ports' for output port {} \ + of configuration '{}' ({})", + index, + config_audio_ports_config.name, + config_audio_ports_config.id, + ) + } + } + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_in_place_f32(&config_audio_ports, BUFFER_SIZE)?; + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| { + format!( + "Error while processing audio with IO configuration '{}' ({})", + config_audio_ports_config.name, config_audio_ports_config.id, + ) + })?; + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::LayoutConfigurableAudioPorts`. +pub fn test_layout_configurable_audio_ports(library: &PluginLibrary, plugin_id: &str) -> Result { + const MAX_TOTAL_CHECKS: u32 = 200; + const MAX_PASSED_CHECKS: u32 = 50; + + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports = match plugin.get_extension::() { + Some(audio_ports) => audio_ports, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let configurable_audio_ports = match plugin.get_extension::() { + Some(extension) => extension, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'configurable-audio-ports' extension.", + )), + }); + } + }; + + let ambisonic = plugin.get_extension::(); + let surround = plugin.get_extension::(); + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + let config_audio_ports = audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?; + + let mut checks_total = 0; + let mut checks_passed = 0; + + while checks_total < MAX_TOTAL_CHECKS && checks_passed < MAX_PASSED_CHECKS { + let requests = random_layout_requests(&config_audio_ports, &mut prng); + + let _span = Span::begin( + "Config", + from_fn(|record| { + for (i, request) in requests.iter().enumerate() { + record.record(&format!("requests.{}", i), *request); + } + }), + ); + + let can_apply = configurable_audio_ports.can_apply_configuration(&requests); + let has_applied = configurable_audio_ports.apply_configuration(&requests); + + if can_apply != has_applied { + anyhow::bail!( + "The plugin returned conflicting results from 'can_apply_configuration' ({}) and \ + 'apply_configuration' ({}) for the following layout: \n{}", + can_apply, + has_applied, + print_layout(&requests) + ); + } + + if has_applied { + checks_total += 1; + checks_passed += 1; + } else { + checks_total += 1; + continue; + } + + let config_audio_ports = audio_ports.config().with_context(|| { + format!( + "Error while querying 'audio-ports' IO configuration after applying the following layout: \n{}", + print_layout(&requests) + ) + })?; + + for request in &requests { + let port = match request.is_input { + true => config_audio_ports.inputs.get(request.port_index as usize), + false => config_audio_ports.outputs.get(request.port_index as usize), + }; + + let port = match port { + Some(port) => port, + None => continue, // we assume that the plugin being overly defensive and accepts configurations with out-of-range port indices, but then ignores the invalid requests instead of rejecting the whole configuration + }; + + if port.channel_count != request.request_info.channel_count() { + anyhow::bail!( + "Wrong number of channels set for {} port (index {}) in response to the layout request: \n{}\n \ + Expected: {}, got: {}", + if request.is_input { "input" } else { "output" }, + request.port_index, + print_layout(&requests), + request.request_info.channel_count(), + port.channel_count, + ); + } + + match request.request_info { + AudioPortsRequestInfo::Ambisonic { config, .. } if port.port_type == AudioPortType::AMBISONIC => { + let result = ambisonic + .as_ref() + .expect("already checked") + .get_config(request.is_input, request.port_index); + + if result.is_none_or(|x| x.normalization != config.normalization && x.ordering != config.ordering) { + anyhow::bail!( + "Wrong ambisonic config set for {} port (index {}) in response to the layout request: \n{}", + if request.is_input { "input" } else { "output" }, + request.port_index, + print_layout(&requests), + ); + } + } + + AudioPortsRequestInfo::Surround { channel_map } if port.port_type == AudioPortType::SURROUND => { + let result_map = surround.as_ref().expect("already checked").get_channel_map( + request.is_input, + request.port_index, + channel_map.len() as u32, + ); + + if channel_map != result_map { + anyhow::bail!( + "Wrong surround map set for {} port (index {}) in response to the layout request: \n{}", + if request.is_input { "input" } else { "output" }, + request.port_index, + print_layout(&requests), + ); + } + } + + _ => {} + } + } + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_in_place_f32(&config_audio_ports, BUFFER_SIZE)?; + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| { + format!( + "Error while processing audio with the following configuration: \n{}", + print_layout(&requests) + ) + })?; + } + + plugin.poll_callback(|_| Ok(()))?; + + if checks_passed == 0 { + return Ok(TestStatus::Warning { + details: Some(format!( + "Tried {} random audio port layouts, but none were accepted.", + checks_total + )), + }); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::LayoutAudioPortsActivation`. +pub fn test_layout_audio_ports_activation(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports = match plugin.get_extension::() { + Some(audio_ports) => audio_ports, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let audio_ports_activation = match plugin.get_extension::() { + Some(extension) => extension, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports-activation' extension.", + )), + }); + } + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + let audio_ports_config = audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?; + + let can_activate_while_processing = audio_ports_activation.can_activate_while_processing(); + let mut input_ports_active = vec![true; audio_ports_config.inputs.len()]; + let mut output_ports_active = vec![true; audio_ports_config.outputs.len()]; + + // 16 different attempts + for _ in 0..16 { + plugin.poll_callback(|_| Ok(()))?; + + for i in 0..audio_ports_config.inputs.len() { + input_ports_active[i] = prng.random_bool(0.5); + audio_ports_activation.set_active(true, i as u32, input_ports_active[i], 0); + } + + for i in 0..audio_ports_config.outputs.len() { + output_ports_active[i] = prng.random_bool(0.5); + audio_ports_activation.set_active(false, i as u32, output_ports_active[i], 0); + } + + let _span = Span::begin( + "AudioPortActivationMask", + record! { + input_mask: format_args!("{}", print_activation_mask(&input_ports_active)), + output_mask: format_args!("{}", print_activation_mask(&output_ports_active)) + }, + ); + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_in_place_f32(&audio_ports_config, BUFFER_SIZE)?; + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + if prng.random_bool(0.5) && can_activate_while_processing { + let Some(ext) = plugin.get_extension::() else { + anyhow::bail!( + "The plugin does not provide a valid 'audio-ports-activation' extension on subsequent \ + calls to get_extension" + ) + }; + + process.activate()?; + + for i in 0..audio_ports_config.inputs.len() { + input_ports_active[i] = prng.random_bool(0.5); + ext.set_active(true, i as u32, input_ports_active[i], 0); + } + + for i in 0..audio_ports_config.outputs.len() { + output_ports_active[i] = prng.random_bool(0.5); + ext.set_active(false, i as u32, output_ports_active[i], 0); + } + } + + for i in 0..audio_ports_config.outputs.len() { + process.set_output_active(i as u32, output_ports_active[i]); + } + + for buffer in process.audio_buffers().iter_mut() { + if let Some(input) = buffer.port().input() { + if input_ports_active[input] { + buffer.fill_white_noise(&mut prng); + } else { + buffer.fill_silence(); + } + } + } + + plugin.poll_callback(); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| { + format!( + "Error while processing audio with input mask {} and output mask {}", + print_activation_mask(&input_ports_active), + print_activation_mask(&output_ports_active) + ) + })?; + } + + Ok(TestStatus::Success { details: None }) +} + +fn print_layout(requests: &[AudioPortsRequest<'_>]) -> String { + requests + .iter() + .map(|r| format!(" - {}", r)) + .collect::>() + .join("\n") +} + +fn print_activation_mask(mask: &[bool]) -> impl Display { + std::fmt::from_fn(move |f| { + f.write_str("0b")?; + for active in mask { + f.write_str(if *active { "1" } else { "0" })?; + } + Ok(()) + }) +} diff --git a/src/tests/plugin_instance/params.rs b/src/tests/plugin_instance/params.rs new file mode 100644 index 0000000..a3490dd --- /dev/null +++ b/src/tests/plugin_instance/params.rs @@ -0,0 +1,757 @@ +//! Tests that focus on parameters. + +use super::PluginInstanceTestCase; +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; +use crate::plugin::ext::note_ports::{NotePortConfig, NotePorts}; +use crate::plugin::ext::params::{Param, ParamInfo, Params}; +use crate::plugin::library::PluginLibrary; +use crate::plugin::process::{AudioBuffers, Event, InputEventQueue, OutputEventQueue, ProcessScope}; +use crate::tests::rng::{NoteGenerator, ParamFuzzer, new_prng}; +use crate::tests::{TestStatus, temporary_file}; +use anyhow::{Context, Result}; +use clap_sys::events::CLAP_EVENT_PARAM_VALUE; +use clap_sys::id::clap_id; +use serde::Serialize; +use std::collections::BTreeMap; +use std::ptr::null_mut; + +/// The fixed buffer size to use for these tests. +const BUFFER_SIZE: u32 = 512; +/// The number of different parameter combinations to try in the parameter fuzzing tests. +pub const FUZZ_NUM_PERMUTATIONS: usize = 50; +/// How many buffers of [`BUFFER_SIZE`] samples to process at each parameter permutation. This +/// allows the state to settle in before moving to the next set of parameter values. +pub const FUZZ_RUNS_PER_PERMUTATION: usize = 5; + +/// The file name we'll use to dump the previous parameter values when a fuzzing test fails. +const PREVIOUS_PARAM_VALUES_FILE_NAME: &str = "param-values-previous.json"; +/// The file name we'll use to dump the current parameter values when a fuzzing test fails. +const CURRENT_PARAM_VALUES_FILE_NAME: &str = "param-values-current.json"; + +/// The format parameter values will be written in when the fuzzing test fails. Used only for +/// serialization. +#[derive(Debug, Serialize)] +struct ParamValue<'a> { + id: clap_id, + name: &'a str, + value: f64, +} + +impl<'a> ParamValue<'a> { + fn from_events(events: Option>, param_info: &'a ParamInfo) -> Vec { + events + .into_iter() + .flatten() + .map(|event| match event { + Event::ParamValue(event) => ParamValue { + id: event.param_id, + name: ¶m_info[&event.param_id].name, + value: event.value, + }, + _ => panic!("Unexpected event type"), + }) + .collect() + } +} + +/// The test for `ProcessingTest::ParamConversions`. +pub fn test_param_conversions(library: &PluginLibrary, plugin_id: &str) -> Result { + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let param_info = params.info().context("Failure while fetching the parameters")?; + + // We keep track of how many parameters support these conversions. A plugin + // should support either conversion either for all of its parameters, or for + // none of them. + + let conversions_per_param = 4000usize.div_ceil(param_info.len()).clamp(5, 100); + let expected_conversions = param_info.len() * conversions_per_param; + + let mut num_supported_value_to_text = 0; + let mut num_supported_text_to_value = 0; + let mut failed_value_to_text_calls: Vec<(String, f64)> = Vec::new(); + let mut failed_text_to_value_calls: Vec<(String, String)> = Vec::new(); + + 'param_loop: for (param_id, param_info) in param_info { + let param_name = ¶m_info.name; + let _span = Span::begin("Param", record! { param_id: param_id, param_name: param_name }); + + 'value_loop: for i in 0..conversions_per_param { + let starting_value = param_info.range.start() + + (param_info.range.end() - param_info.range.start()) * (i as f64 / (conversions_per_param - 1) as f64); + + // If the plugin rounds string representations then `value` may very + // will not roundtrip correctly, so we'll start at the string + // representation + let starting_text = match params.value_to_text(param_id, starting_value)? { + Some(text) => text, + None => { + failed_value_to_text_calls.push((param_name.to_owned(), starting_value)); + continue 'param_loop; + } + }; + num_supported_value_to_text += 1; + let reconverted_value = match params.text_to_value(param_id, &starting_text)? { + Some(value) => value, + // We can't test text to value conversions without a text + // value provided by the plugin, but if the plugin doesn't + // support this then we should still continue testing + // whether the value to text conversion works consistently + None => { + failed_text_to_value_calls.push((param_name.to_owned(), starting_text)); + continue 'value_loop; + } + }; + num_supported_text_to_value += 1; + + let reconverted_text = params.value_to_text(param_id, reconverted_value)?.with_context(|| { + format!("Failure in repeated value to text conversion for parameter {param_id} ('{param_name}')") + })?; + // Both of these are produced by the plugin, so they should be equal + if starting_text != reconverted_text { + anyhow::bail!( + "Converting {starting_value:?} to a string, back to a value, and then back to a string again for \ + parameter '{param_name}' ({param_id}) results in '{starting_text}' -> {reconverted_value:?} -> \ + '{reconverted_text}', which is not consistent." + ); + } + + // And one last hop back for good measure + let final_value = params.text_to_value(param_id, &reconverted_text)?.with_context(|| { + format!("Failure in repeated text to value conversion for parameter {param_id} ('{param_name}')") + })?; + if final_value != reconverted_value { + anyhow::bail!( + "Converting {starting_value:?} to a string, back to a value, back to a string, and then back to a \ + value again for parameter '{param_name}' ({param_id}) results in '{starting_text}' -> \ + {reconverted_value:?} -> '{reconverted_text}' -> {final_value:?}, which is not consistent." + ); + } + } + } + + plugin.poll_callback(|_| Ok(()))?; + + if num_supported_value_to_text == 0 || num_supported_text_to_value == 0 { + return Ok(TestStatus::Success { + details: Some(String::from( + "The plugin does not support text-to-value and value-to-text parameter conversions", + )), + }); + } + + if num_supported_value_to_text != expected_conversions { + let failed_value_to_text_calls = failed_value_to_text_calls + .into_iter() + .take(10) + .map(|(name, value)| format!("\n - {name}: {value:.4}")) + .collect::>() + .join(""); + + anyhow::bail!( + "'clap_plugin_params::value_to_text()' returned true for {num_supported_value_to_text} out of \ + {expected_conversions} calls. This function is expected to be supported for either none of the \ + parameters or for all of them. Examples of failing conversions were: {failed_value_to_text_calls}" + ); + } + + if num_supported_text_to_value != expected_conversions { + let failed_text_to_value_calls = failed_text_to_value_calls + .into_iter() + .take(10) + .map(|(name, text)| format!("\n - {name}: '{text}'")) + .collect::>() + .join(""); + + anyhow::bail!( + "'clap_plugin_params::text_to_value()' returned true for {num_supported_text_to_value} out of \ + {expected_conversions} calls. This function is expected to be supported for either none of the \ + parameters or for all of them. Examples of failing conversions were: {failed_text_to_value_calls}" + ); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `ProcessingTest::ParamChangeEvents`. +pub fn test_param_set_events(library: &PluginLibrary, plugin_id: &str, null_cookies: bool) -> Result { + // first, flush run + let span = Span::begin("FlushRun", ()); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + let param_info = params.info().context("Failure while fetching the parameters")?; + let mut param_events = ParamFuzzer::new(¶m_info) + .randomize_params_at(&mut new_prng(), 0) + .collect::>(); + + if param_events.is_empty() { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not have any automatable parameters")), + }); + } + + if null_cookies { + for event in param_events.iter_mut() { + match event { + Event::ParamValue(event) => event.cookie = null_mut(), + event => panic!("Unexpected event {event:?}"), + } + } + } + + let flush_param_values = { + let initial_param_values = param_get_values(¶ms)?; + + plugin.poll_callback(|_| Ok(()))?; + + let input_queue = InputEventQueue::new(); + input_queue.add_events(param_events.iter().cloned()); + params.flush(&input_queue, &OutputEventQueue::new()); + + plugin.poll_callback(|_| Ok(()))?; + + let flush_param_values = param_get_values(¶ms)?; + if flush_param_values == initial_param_values { + anyhow::bail!("After calling 'clap_plugin_params::flush()', the parameter values did not change"); + } + + flush_param_values + }; + + span.finish(()); + + // second run, use process this time + let span = Span::begin("ProcessRun", ()); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => anyhow::bail!("The second instance does not implement the 'params' extension."), + }; + + // we have to recreate the events because of cookies (they can be different between plugin instances) + let param_info = params.info().context("Failure while fetching the parameters")?; + let mut param_events = ParamFuzzer::new(¶m_info) + .with_no_cookies(null_cookies) + .randomize_params_at(&mut new_prng(), 0) + .collect::>(); + + if null_cookies { + for event in param_events.iter_mut() { + match event { + Event::ParamValue(event) => event.cookie = null_mut(), + event => panic!("Unexpected event {event:?}"), + } + } + } + + let process_param_values = { + let initial_param_values = param_get_values(¶ms)?; + + plugin.poll_callback(|_| Ok(()))?; + + plugin.on_audio_thread(|plugin| { + let mut buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut process = ProcessScope::new(&plugin, &mut buffers)?; + + plugin.poll_callback(); + process.add_events(param_events); + process.run() + })?; + + plugin.poll_callback(|_| Ok(()))?; + + let process_param_values = param_get_values(¶ms)?; + if process_param_values == initial_param_values { + anyhow::bail!( + "After sending parameter changes via 'clap_plugin::process()', the parameter values did not change" + ); + } + + process_param_values + }; + + span.finish(()); + + if let Some(diff) = param_generate_diff(&flush_param_values, &process_param_values, ¶ms)? { + anyhow::bail!( + "The resulting parameter values after calling 'clap_plugin_params::flush()' were different from the \ + resulting parameter values after sending the same parameter changes via 'clap_plugin::process()': \n{}", + diff + ); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `ProcessingTest::ParamFuzzBasic` and `ProcessingTest::ParamFuzzBounds`. +pub fn test_param_fuzz_basic(library: &PluginLibrary, plugin_id: &str, snap_to_bounds: bool) -> Result { + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + // Both audio and note ports are optional + let audio_ports = plugin.get_extension::(); + let note_ports = plugin.get_extension::(); + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let audio_ports_config = audio_ports + .map(|ports| ports.config()) + .transpose() + .context("Could not fetch the audio port config")? + .unwrap_or_default(); + let note_ports_config = note_ports + .map(|ports| ports.config()) + .transpose() + .context("Could not fetch the note port config")? + .unwrap_or_default(); + + // For each set of runs we'll generate new parameter values, and if the plugin supports notes + // we'll also generate note events. + let param_info = params.info().context("Could not fetch the parameters")?; + let param_fuzzer = ParamFuzzer::new(¶m_info).snap_to_bounds(snap_to_bounds); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + + // We'll keep track of the current and the previous set of parameter value so we can write them + // to a file if the test fails + let mut current_events: Option>; + let mut previous_events: Option> = None; + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + + for permutation_no in 1..=FUZZ_NUM_PERMUTATIONS { + current_events = Some(param_fuzzer.randomize_params_at(&mut prng, 0).collect()); + + let run_result = plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + process.add_events(current_events.clone().unwrap()); + + for _ in 0..FUZZ_RUNS_PER_PERMUTATION { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }); + + // If the run failed we'll want to write the parameter values to a file first + if run_result.is_err() { + let (previous_param_values_file_path, previous_param_values_file) = temporary_file( + &PluginInstanceTestCase::ParamFuzzBasic.to_string(), + plugin_id, + PREVIOUS_PARAM_VALUES_FILE_NAME, + )?; + + let (current_param_values_file_path, current_param_values_file) = temporary_file( + &PluginInstanceTestCase::ParamFuzzBasic.to_string(), + plugin_id, + CURRENT_PARAM_VALUES_FILE_NAME, + )?; + + serde_json::to_writer_pretty( + previous_param_values_file, + &ParamValue::from_events(previous_events, ¶m_info), + )?; + serde_json::to_writer_pretty( + current_param_values_file, + &ParamValue::from_events(current_events, ¶m_info), + )?; + + // This is a bit weird and there may be a better way to do this, but we only want to + // write the parameter values if we know the run has failed, and we only know the + // filename after writing those values to a file + return Err(run_result + .with_context(|| { + format!( + "Invalid output detected in parameter value permutation {} of {} ('{}' and '{}' contain the \ + current and previous parameter values)", + permutation_no, + FUZZ_NUM_PERMUTATIONS, + current_param_values_file_path.display(), + previous_param_values_file_path.display(), + ) + }) + .unwrap_err()); + } + + std::mem::swap(&mut previous_events, &mut current_events); + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `ProcessingTest::ParamFuzzSampleAccurate`. +pub fn test_param_fuzz_sample_accurate(library: &PluginLibrary, plugin_id: &str) -> Result { + const INTERVALS: &[u32] = &[1000, 100, 10]; + + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + // Both audio and note ports are optional + let audio_ports = plugin.get_extension::(); + let note_ports = plugin.get_extension::(); + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let audio_ports_config = audio_ports + .map(|ports| ports.config()) + .transpose() + .context("Could not fetch the audio port config")? + .unwrap_or_default(); + + let note_ports_config = note_ports + .map(|ports| ports.config()) + .transpose() + .context("Could not fetch the note port config")? + .unwrap_or_default(); + + let param_info = params.info().context("Could not fetch the parameters")?; + + // For each set of runs we'll generate new parameter values, and if the plugin supports notes + // we'll also generate note events. + let param_fuzzer = ParamFuzzer::new(¶m_info); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut current_events: Option> = None; + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + + for &interval in INTERVALS { + let _span = Span::begin("Interval", record! { interval: interval }); + let num_steps = (interval * 4).div_ceil(BUFFER_SIZE); + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + let mut current_sample = 0; + for _ in 0..num_steps { + while current_sample < BUFFER_SIZE { + let events: Vec = param_fuzzer.randomize_params_at(&mut prng, current_sample).collect(); + process.add_events(events.clone()); + current_events = Some(events); + current_sample += interval; + } + + current_sample -= BUFFER_SIZE; + + // Audio and MIDI/note events are randomized in accordance to what the plugin + // supports + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + })?; + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `ProcessingTest::ParamFuzzModulation`. +pub fn test_param_fuzz_modulation(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports = match plugin.get_extension::() { + Some(audio_ports) => audio_ports.config().context("Could not fetch the audio port config")?, + None => AudioPortConfig::default(), + }; + + let note_ports = match plugin.get_extension::() { + Some(note_ports) => note_ports.config().context("Could not fetch the note port config")?, + None => NotePortConfig::default(), + }; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + let param_info = params.info().context("Could not fetch the parameters")?; + let param_fuzzer = ParamFuzzer::new(¶m_info); + let mut note_rng = NoteGenerator::new(¬e_ports).with_params(¶m_info); + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports, BUFFER_SIZE); + + plugin.poll_callback(|_| Ok(()))?; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(param_fuzzer.generate_events(&mut prng, process.max_block_size())); + process.add_events(note_rng.generate_events(&mut prng, process.max_block_size())); + process.run()?; + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `ProcessingTest::ParamSetWrongNamespace`. +pub fn test_param_set_wrong_namespace(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let param_info = params.info().context("Failure while fetching the parameters")?; + let initial_param_values = param_get_values(¶ms)?; + + // We'll generate random parameter set events, but we'll change the namespace ID to something + // else. The parameter values should thus not update its parameter values. + const INCORRECT_NAMESPACE_ID: u16 = 0xb33f; + let param_fuzzer = ParamFuzzer::new(¶m_info); + let mut random_param_set_events: Vec<_> = param_fuzzer.randomize_params_at(&mut prng, 0).collect(); + + for event in random_param_set_events.iter_mut() { + match event { + Event::ParamValue(event) => event.header.space_id = INCORRECT_NAMESPACE_ID, + event => panic!("Unexpected event {event:?}"), + } + } + + plugin.on_audio_thread(|plugin| { + let mut buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut process = ProcessScope::new(&plugin, &mut buffers)?; + + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(random_param_set_events); + process.run() + })?; + + // We'll check that the plugin has these sames values after reloading the state. These values + // are rounded to the tenth decimal to provide some leeway in the serialization and + // deserialization process. + let actual_param_values = param_get_values(¶ms)?; + + plugin.poll_callback(|_| Ok(()))?; + + if actual_param_values == initial_param_values { + Ok(TestStatus::Success { details: None }) + } else { + Ok(TestStatus::Failed { + details: Some(format!( + "Sending events with type ID {CLAP_EVENT_PARAM_VALUE} (CLAP_EVENT_PARAM_VALUE) and namespace ID \ + {INCORRECT_NAMESPACE_ID:#x} to the plugin caused its parameter values to change. This should not \ + happen. The plugin may not be checking the event's namespace ID." + )), + }) + } +} + +/// The test for `ProcessingTest::ParamDefaultValues`. +pub fn test_param_default_values(library: &PluginLibrary, plugin_id: &str) -> Result { + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let param_info = params.info().context("Failure while fetching the parameters")?; + + for (param_id, param_info) in param_info { + let default_value = params + .get(param_id) + .with_context(|| format!("Could not get value for parameter {param_id}"))?; + + if !param_compare_approx(¶m_info, default_value, param_info.default) { + anyhow::bail!( + "The default value for parameter {param_id} ('{}') is {}, but the actual parameter value after \ + initialization is {}.", + param_info.name, + param_info.default, + default_value + ); + } + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +pub fn param_get_values(params: &Params) -> Result> { + params + .info()? + .keys() + .map(|param_id| params.get(*param_id).map(|value| (*param_id, value))) + .collect::>>() +} + +pub fn param_compare_approx(param: &Param, actual: f64, expected: f64) -> bool { + if param.is_stepped() { + let actual = actual.round() as i64; + let expected = expected.round() as i64; + + actual == expected + } else { + let actual = (actual - param.range.start()) / (param.range.end() - param.range.start()); + let expected = (expected - param.range.start()) / (param.range.end() - param.range.start()); + + (actual - expected).abs() <= 1e-4 // 0.01% of the range + } +} + +/// Build a string containing differences between two sets of parameters, pretty formatted +pub fn param_generate_diff( + actual: &BTreeMap, + expected: &BTreeMap, + params: &Params, +) -> Result> { + let param_info = params.info()?; + + let mut diff = param_info + .iter() + .filter_map(|(param_id, info)| { + let value_a = actual.get(param_id); + let value_b = expected.get(param_id); + + let string_a = value_a.and_then(|&value| params.value_to_text(*param_id, value).ok().flatten()); + let string_b = value_b.and_then(|&value| params.value_to_text(*param_id, value).ok().flatten()); + + // If we have strings, and they're equal, then we consider the parameters to be equal, even if the values are not exactly equal. + // This is because some plugins may round parameter values when converting them to strings, and we want to allow for that. + if let (Some(string_a), Some(string_b)) = (string_a.as_ref(), string_b.as_ref()) + && string_a == string_b + { + return None; + } + + if let (Some(value_a), Some(value_b)) = (value_a, value_b) + && param_compare_approx(info, *value_a, *value_b) + { + return None; + } + + let print_a = match (string_a, value_a) { + (Some(string_a), Some(value_a)) => format!("{} ({:.4})", string_a, value_a), + (None, Some(value_a)) => format!("{:.4}", value_a), + _ => "missing".to_string(), + }; + + let print_b = match (string_b, value_b) { + (Some(string_b), Some(value_b)) => format!("{} ({:.4})", string_b, value_b), + (None, Some(value_b)) => format!("{:.4}", value_b), + _ => "missing".to_string(), + }; + + Some(format!(" - {} ({}) - {} vs {}", info.name, param_id, print_a, print_b)) + }) + .collect::>(); + + let num_diffs = diff.len(); + if num_diffs == 0 { + Ok(None) + } else if num_diffs > 5 { + diff.truncate(5); + Ok(Some(format!("{}\n...and {} more", diff.join("\n"), num_diffs - 5))) + } else { + Ok(Some(diff.join("\n"))) + } +} diff --git a/src/tests/plugin_instance/processing.rs b/src/tests/plugin_instance/processing.rs new file mode 100644 index 0000000..2ab9253 --- /dev/null +++ b/src/tests/plugin_instance/processing.rs @@ -0,0 +1,793 @@ +//! Contains most of the boilerplate around testing audio processing. + +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; +use crate::plugin::ext::note_ports::{NotePortConfig, NotePorts}; +use crate::plugin::ext::tail::Tail; +use crate::plugin::ext::voice_info::VoiceInfo; +use crate::plugin::instance::{CallbackEvent, ProcessStatus}; +use crate::plugin::library::PluginLibrary; +use crate::plugin::process::{AudioBuffers, ConstantMask, ProcessScope, check_channel_quiet}; +use crate::tests::TestStatus; +use crate::tests::rng::{NoteGenerator, new_prng}; +use anyhow::{Context, Result}; +use clap_sys::ext::voice_info::CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES; +use either::Either; +use rand::RngExt; +use std::time::Instant; + +const BUFFER_SIZE: u32 = 512; + +/// The test for `PluginTestCase::ProcessAudioOutOfPlaceBasic` and `PluginTestCase::ProcessAudioInPlaceBasic`. +pub fn test_process_audio_basic(library: &PluginLibrary, plugin_id: &str, in_place: bool) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let mut audio_buffers = if in_place { + AudioBuffers::new_in_place_f32(&audio_ports_config, BUFFER_SIZE)? + } else { + AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE) + }; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.run()?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +// The test for `PluginTestCase::ProcessAudioOutOfPlaceDouble`. +pub fn test_process_audio_double(library: &PluginLibrary, plugin_id: &str, in_place: bool) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + plugin.poll_callback(|_| Ok(()))?; + + let has_double_support = audio_ports_config + .inputs + .iter() + .chain(audio_ports_config.outputs.iter()) + .any(|port| port.supports_double_sample_size); + + if !has_double_support { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not support 64-bit floating point audio.")), + }); + } + + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut audio_buffers = if in_place { + AudioBuffers::new_in_place_f64(&audio_ports_config, BUFFER_SIZE)? + } else { + AudioBuffers::new_out_of_place_f64(&audio_ports_config, BUFFER_SIZE) + }; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessAudioDenormal`. +pub fn test_process_audio_denormals(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'audio-ports' extension.", + )), + }); + } + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + if audio_ports_config.inputs.is_empty() { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin implements the 'audio-ports' extension but it does not have any input audio ports.", + )), + }); + } + + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=128); + + let time_normal = Instant::now(); + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + process.set_allow_denormals(true); + + for _ in 0..50 { + plugin.poll_callback(); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.audio_buffers().fill_white_noise(&mut prng); + process.run()?; + } + + Ok(()) + })?; + let time_normal = time_normal.elapsed(); + + plugin.poll_callback(|_| Ok(()))?; + + let time_denormal = Instant::now(); + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + process.set_allow_denormals(true); + + for _ in 0..50 { + for buffer in process.audio_buffers().iter_mut() { + if buffer.port().input().is_some() { + buffer.set_input_constant_mask(ConstantMask::DYNAMIC); + for channel in 0..buffer.channels() { + match buffer.channel_mut(channel) { + Either::Left(c) => c.fill_with(|| prng.random_range(-f32::MIN_POSITIVE..f32::MIN_POSITIVE)), + Either::Right(c) => { + c.fill_with(|| prng.random_range(-f64::MIN_POSITIVE..f64::MIN_POSITIVE)) + } + } + } + } + } + + plugin.poll_callback(); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + })?; + let time_denormal = time_denormal.elapsed(); + + plugin.poll_callback(|_| Ok(()))?; + + let ratio = time_denormal.as_secs_f64() / time_normal.as_secs_f64(); + if ratio > 2.0 { + return Ok(TestStatus::Warning { + details: Some(format!( + "The plugin took {:.2}x longer to process denormals, you should set flush-to-zero flags or avoid \ + denormals in some other way.", + ratio + )), + }); + } + + if ratio > 1.2 { + return Ok(TestStatus::Success { + details: Some(format!("The plugin took {:.2}x longer to process denormals", ratio)), + }); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessNoteOutOfPlaceBasic` and `PluginTestCase::ProcessNoteInconsistent`. This test is very similar to +/// `ProcessAudioOutOfPlaceBasic`, but it requires the `note-ports` extension, sends notes and/or +/// MIDI to the plugin, and doesn't require the `audio-ports` extension. +pub fn test_process_note_out_of_place( + library: &PluginLibrary, + plugin_id: &str, + inconsistent: bool, + wildcard: bool, +) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + // You can have note/MIDI-only plugins, so not having any audio ports is perfectly fine here + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not implement the 'note-ports' extension.", + )), + }); + } + }; + + if note_ports_config.inputs.is_empty() { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin implements the 'note-ports' extension but it does not have any input note ports.", + )), + }); + } + + if wildcard && !note_ports_config.inputs.iter().any(|x| x.supports_clap()) { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin does not have any input note ports that support CLAP events", + )), + }); + } + + plugin.on_audio_thread(|plugin| -> Result<()> { + // We'll fill the input event queue with (consistent) random CLAP note and/or MIDI + // events depending on what's supported by the plugin supports + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + // voice_info::get needs to be called in an active state + process.activate()?; + + let supports_overlapping_notes = plugin.on_main_thread(|plugin| { + plugin + .get_extension::() + .and_then(|x| x.get()) + .is_some_and(|info| (info.flags & CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES) != 0) + }); + + if inconsistent { + note_rng = note_rng.with_inconsistent_events(); + } + + if supports_overlapping_notes { + note_rng = note_rng.with_overlapping_notes(); + } + + if wildcard { + note_rng = note_rng.with_wildcard_events(); + } + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessVaryingSampleRates`. +pub fn test_process_varying_sample_rates(library: &PluginLibrary, plugin_id: &str) -> Result { + const SAMPLE_RATES: &[f64] = &[ + 8000.0, 22050.0, 44100.0, 48000.0, 88200.0, 96000.0, 192000.0, 384000.0, 768000.0, 1234.5678, 12345.678, + 45678.901, 123456.78, + ]; + + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'audio-ports' IO configuration")? + .unwrap_or_default(); + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + + for &sample_rate in SAMPLE_RATES { + let _span = Span::begin("SampleRate", record! { sample_rate: sample_rate }); + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::with_config(&plugin, &mut audio_buffers, sample_rate, 1)?; + + for _ in 0..5 { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| format!("Error while processing with {:.2}hz sample rate", sample_rate))?; + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessVaryingBlockSizes`. +pub fn test_process_varying_block_sizes(library: &PluginLibrary, plugin_id: &str) -> Result { + const BLOCK_SIZES: &[u32] = &[1, 256, 1024, 4096, 16384, 1536, 10, 17, 2027]; + + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'audio-ports' IO configuration")? + .unwrap_or_default(); + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + for &buffer_size in BLOCK_SIZES { + let _span = Span::begin("BlockSize", record! { buffer_size: buffer_size }); + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, buffer_size); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + let num_iters = (16384 / buffer_size).min(5); + + for _ in 0..num_iters { + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, buffer_size)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| format!("Error while processing with buffer size of {}", buffer_size))?; + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessRandomBlockSizes`. +pub fn test_process_random_block_sizes(library: &PluginLibrary, plugin_id: &str) -> Result { + const MAX_BUFFER_SIZE: u32 = 2048; + + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'audio-ports' IO configuration")? + .unwrap_or_default(); + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, MAX_BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..20 { + let block_size = if prng.random_bool(0.8) { + prng.random_range(2..=MAX_BUFFER_SIZE) + } else { + 1 + }; + + plugin.poll_callback(); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, block_size)); + process + .run_with(block_size) + .with_context(|| format!("Error while processing with buffer size of {}", block_size))?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessSleepConstantMask`. +pub fn test_process_sleep_constant_mask(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + let mut has_received_constant_output = false; + let mut has_received_constant_flag = false; + let mut check_buffers = |buffers: &AudioBuffers| -> Result<()> { + for buffer in buffers.iter() { + if buffer.port().output().is_none() { + continue; + } + + for channel in 0..buffer.channels() { + let is_constant = check_channel_quiet(buffer.channel(channel), true); + let marked_constant = buffer.get_output_constant_mask().is_channel_constant(channel); + + // congruency of these two is checked in [`ProcessScope::run`] + + if marked_constant { + has_received_constant_flag |= true; + } + + if is_constant.is_ok() { + has_received_constant_output |= true; + } + } + } + + Ok(()) + }; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + // block 1: silent inputs, see what the plugin does + let span = Span::begin("BlockPrerollSilent", ()); + process.run()?; + check_buffers(process.audio_buffers()).context("Block preroll silent")?; + span.finish(()); + + plugin.poll_callback(); + + // block 2: randomize inputs, see if the plugin tracks constant channels + let span = Span::begin("BlockActiveInput", ()); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + check_buffers(process.audio_buffers()).context("Block random input")?; + span.finish(()); + + plugin.poll_callback(); + + // block 3-40: silent inputs again, see if the plugin updates the constant mask accordingly + // 40 blocks to give the output tail to fully decay to silence if there is any reverb/delay + let span = Span::begin("BlockTailSilent", ()); + process.audio_buffers().fill_silence(); + process.add_events(note_rng.stop_all_voices(0)); + for _ in 3..=40 { + process.run()?; + check_buffers(process.audio_buffers())?; + } + span.finish(()); + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + if !has_received_constant_flag && has_received_constant_output { + return Ok(TestStatus::Success { + details: Some(String::from("The plugin never set the constant flag on any output")), + }); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessSleepProcessStatus`. +pub fn test_process_sleep_process_status(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + let mut has_ever_slept = false; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let tail = plugin.get_extension::(); + + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + let mut is_sleeping = false; + let mut quiet_time = 0; + + for is_quiet in [true, false, true, false, true, true] { + let _span = if is_quiet { + Span::begin("BlockQuiet", ()) + } else { + Span::begin("BlockActive", ()) + }; + + for _ in 0..10 { + if is_quiet { + process.add_events(note_rng.stop_all_voices(0)); + process.audio_buffers().fill_silence(); + } else { + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.audio_buffers().fill_white_noise(&mut prng); + } + + plugin.poll_callback_with(|_, event| match event { + CallbackEvent::RequestProcess => { + is_sleeping = false; + Ok(()) + } + + _ => Ok(()), + })?; + + let status = process.run()?; + + if is_sleeping && is_quiet { + for buffer in process.audio_buffers().iter() { + let Some(output) = buffer.port().output() else { + continue; + }; + + for channel in 0..buffer.channels() { + let is_constant = check_channel_quiet(buffer.channel(channel), true); + if let Err(db) = is_constant { + anyhow::bail!( + "The plugin is sleeping but output port {output}, channel {channel} contains \ + non-constant data ({db:.2} dBFS)", + ); + } + } + } + } + + has_ever_slept |= is_sleeping; + + match status { + ProcessStatus::Continue => is_sleeping = false, + ProcessStatus::Sleep => is_sleeping = true, + ProcessStatus::ContinueIfNotQuiet => { + let is_output_quiet = process + .audio_buffers() + .iter() + .filter(|b| b.port().output().is_some()) + .all(|b| b.get_output_constant_mask().are_all_channels_constant(b.channels())); + + is_sleeping = is_output_quiet; + } + + ProcessStatus::Tail => { + let tail = match &tail { + Some(tail) => tail.get(), + None => { + anyhow::bail!( + "Plugin returned `CLAP_PROCESS_TAIL` process status but does not implement the \ + 'tail' extension." + ); + } + }; + + is_sleeping = tail < quiet_time; + if is_quiet { + quiet_time += BUFFER_SIZE; + } else { + quiet_time = 0; + } + } + } + } + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + if !has_ever_slept { + return Ok(TestStatus::Success { + details: Some(String::from("The plugin never went to sleep during the test.")), + }); + } + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::ProcessResetReactivate`. +pub fn test_process_reset_reactivate(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'audio-ports' IO configuration")? + .unwrap_or_default(); + + let note_ports_config = plugin + .get_extension::() + .map(|x| x.config()) + .transpose() + .context("Error while querying 'note-ports' IO configuration")? + .unwrap_or_default(); + + let result = plugin.on_audio_thread(|plugin| -> Result { + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-4..=64); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + // first run, the "control" + let span = Span::begin("InitialRun", ()); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + span.finish(()); + + plugin.poll_callback(); + process.deactivate(); + note_rng.reset(); + + // second run, deactivate and reactivate the plugin + let span = Span::begin("ReactivateRun", ()); + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + span.finish(()); + + plugin.poll_callback(); + process.reset(); + note_rng.reset(); + + // third run, reset the plugin + let span = Span::begin("ResetRun", ()); + process.audio_buffers().fill_white_noise(&mut new_prng()); + process.add_events(note_rng.generate_events(&mut new_prng(), BUFFER_SIZE)); + process.run()?; + span.finish(()); + + plugin.poll_callback(); + + Ok(TestStatus::Success { details: None }) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(result) +} diff --git a/src/tests/plugin_instance/state.rs b/src/tests/plugin_instance/state.rs new file mode 100644 index 0000000..6484892 --- /dev/null +++ b/src/tests/plugin_instance/state.rs @@ -0,0 +1,308 @@ +//! Tests surrounding state handling. + +use super::PluginInstanceTestCase; +use crate::plugin::ext::params::{Params, ParamsRescan}; +use crate::plugin::ext::state::State; +use crate::plugin::instance::CallbackEvent; +use crate::plugin::library::PluginLibrary; +use crate::plugin::process::{InputEventQueue, OutputEventQueue}; +use crate::tests::plugin_instance::params::{param_generate_diff, param_get_values}; +use crate::tests::rng::{ParamFuzzer, new_prng}; +use crate::tests::{TestStatus, temporary_file}; +use anyhow::{Context, Result}; +use rand::RngExt; +use std::io::Write; + +/// The file name we'll use to dump the expected state when a test fails. +const EXPECTED_STATE_FILE_NAME: &str = "state-expected"; +/// The file name we'll use to dump the actual state when a test fails. +const ACTUAL_STATE_FILE_NAME: &str = "state-actual"; + +/// The test for `PluginTestCase::StateInvalidEmpty`. +pub fn test_state_invalid_empty(library: &PluginLibrary, plugin_id: &str) -> Result { + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + + plugin.init().context("Error during initialization")?; + let state = match plugin.get_extension::() { + Some(state) => state, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'state' extension.")), + }); + } + }; + + let result = state.load(&[]); + + plugin.poll_callback(|_| Ok(()))?; + + match result { + Ok(_) => Ok(TestStatus::Warning { + details: Some(String::from( + "The plugin returned true when 'clap_plugin_state::load()' was called when an empty state, this is \ + likely a bug.", + )), + }), + Err(_) => Ok(TestStatus::Success { details: None }), + } +} + +/// The test for `PluginTestCase::StateInvalidRandom`. +pub fn test_state_invalid_random(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + + plugin.init().context("Error during initialization")?; + + let state = match plugin.get_extension::() { + Some(state) => state, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'state' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let mut random_data = vec![0u8; 1024 * 1024]; + let mut succeeded = false; + + for _ in 0..3 { + prng.fill(&mut random_data[..]); + succeeded |= state.load(&random_data).is_ok(); + } + + plugin.poll_callback(|_| Ok(()))?; + + match succeeded { + false => Ok(TestStatus::Success { details: None }), + true => Ok(TestStatus::Warning { + details: Some(String::from( + "The plugin loaded random bytes successfully, which is unexpected, but the plugin did not crash.", + )), + }), + } +} + +/// The test for `PluginTestCase::StateReproducibilityNullCookies` and `PluginTestCase::StateReproducibilityBasic`. +/// See the description of these test for a detailed explanation, but we essentially check if saving a loaded state results in the +/// same state file, and whether a plugin's parameters are the same after loading the state. +/// +/// The `zero_out_cookies` parameter offers an alternative on this test that sends parameter change +/// events with all cookies set to null pointers. The plugin should behave identically when this +/// happens. +pub fn test_state_reproducibility( + library: &PluginLibrary, + plugin_id: &str, + buffered_streams: bool, + binary_equality: bool, +) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + + // We'll drop and reinitialize the plugin later + let (expected_state, expected_param_values) = { + plugin.init().context("Error during initialization")?; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + let state = match plugin.get_extension::() { + Some(state) => state, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'state' extension.")), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let param_info = params + .info() + .context("Failure while fetching the plugin's parameters")?; + + // We can't compare the values from these events direclty as the plugin + // may round the values during the parameter set + let param_fuzzer = ParamFuzzer::new(¶m_info); + let param_events: Vec<_> = param_fuzzer.randomize_params_at(&mut prng, 0).collect(); + + { + let input_queue = InputEventQueue::new(); + let output_queue = OutputEventQueue::new(); + input_queue.add_events(param_events); + params.flush(&input_queue, &output_queue); + } + + plugin.poll_callback(|_| Ok(()))?; + + // We'll check that the plugin has these sames values after reloading the state. These + // values are rounded to the tenth decimal to provide some leeway in the serialization and + // deserialization process. + let expected_param_values = param_get_values(¶ms)?; + let expected_state = if buffered_streams { + state.save_buffered(23)? + } else { + state.save()? + }; + + plugin.poll_callback(|_| Ok(()))?; + + (expected_state, expected_param_values) + }; + + // Now we'll recreate the plugin instance, load the state, and check whether the values are + // consistent and whether saving the state again results in an idential state file. This ends up + // being a bit of a lengthy test case because of this multiple initialization. Before + // continueing, we'll make sure the first plugin instance no longer exists. + drop(plugin); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance a second time")?; + + plugin + .init() + .context("Error while initializing the second plugin instance")?; + + let params = match plugin.get_extension::() { + Some(params) => params, + None => { + // I sure hope that no plugin will ever hit this + return Ok(TestStatus::Skipped { + details: Some(String::from("The plugin does not implement the 'params' extension.")), + }); + } + }; + + let state = match plugin.get_extension::() { + Some(state) => state, + None => { + return Ok(TestStatus::Skipped { + details: Some(String::from( + "The plugin's second instance does not implement the 'state' extension.", + )), + }); + } + }; + + plugin.poll_callback(|_| Ok(()))?; + + let before_load_params = params.info()?; + let before_load_values = param_get_values(¶ms)?; + + if buffered_streams { + // This is a buffered load that only loads 17 bytes at a time. Why 17? Because. + state.load_buffered(&expected_state, 17)?; + } else { + state.load(&expected_state)?; + } + + let mut param_rescan_values = false; + let mut param_rescan_info = false; + let mut param_rescan_all = false; + + plugin.poll_callback(|event| { + if let CallbackEvent::ParamsRescan(rescan) = event { + match rescan { + ParamsRescan::Values => param_rescan_values = true, + ParamsRescan::Text => {} // this could be checked as well + ParamsRescan::Info => param_rescan_info = true, + ParamsRescan::All => { + param_rescan_values = true; + param_rescan_info = true; + param_rescan_all = true; + } + } + } + + Ok(()) + })?; + + let after_load_params = params.info()?; + let after_load_values = param_get_values(¶ms)?; + + if !param_rescan_values && let Some(diff) = param_generate_diff(&before_load_values, &after_load_values, ¶ms)? { + anyhow::bail!( + "After reloading the state, these parameter values changed without a rescan request: \n{}", + diff + ); + } + + if !param_rescan_all { + if before_load_params.keys().collect::>() != after_load_params.keys().collect::>() { + anyhow::bail!("After reloading the state, the parameter list changed without a rescan request."); + } + + for (previous, current) in before_load_params.values().zip(after_load_params.values()) { + let missed_rescan = match current.needs_rescan(previous) { + Some(ParamsRescan::Info) => !param_rescan_info, + Some(ParamsRescan::All) => true, + _ => continue, + }; + + if missed_rescan { + anyhow::bail!( + "After reloading the state, the parameter '{}' changed without a rescan request.", + current.name + ); + } + } + } + + if let Some(diff) = param_generate_diff(&after_load_values, &expected_param_values, ¶ms)? { + anyhow::bail!( + "After reloading the state, these parameter values do not match the previously saved values: \n{}", + diff + ); + } + + plugin.poll_callback(|_| Ok(()))?; + + let actual_state = state.save()?; + + plugin.poll_callback(|_| Ok(()))?; + + if !binary_equality || actual_state == expected_state { + Ok(TestStatus::Success { details: None }) + } else { + let (expected_state_file_path, mut expected_state_file) = temporary_file( + &PluginInstanceTestCase::StateReproducibilityBinary.to_string(), + plugin_id, + EXPECTED_STATE_FILE_NAME, + )?; + + let (actual_state_file_path, mut actual_state_file) = temporary_file( + &PluginInstanceTestCase::StateReproducibilityBinary.to_string(), + plugin_id, + ACTUAL_STATE_FILE_NAME, + )?; + + expected_state_file.write_all(&expected_state)?; + actual_state_file.write_all(&actual_state)?; + + Ok(TestStatus::Failed { + details: Some(format!( + "The saved state after loading differs from the original saved state. \nExpected: '{}'. \nActual: \ + '{}'.", + expected_state_file_path.display(), + actual_state_file_path.display(), + )), + }) + } +} diff --git a/src/tests/plugin_instance/transport.rs b/src/tests/plugin_instance/transport.rs new file mode 100644 index 0000000..061a09b --- /dev/null +++ b/src/tests/plugin_instance/transport.rs @@ -0,0 +1,181 @@ +use crate::cli::tracing::{Span, record}; +use crate::plugin::ext::audio_ports::{AudioPortConfig, AudioPorts}; +use crate::plugin::ext::note_ports::{NotePortConfig, NotePorts}; +use crate::plugin::library::PluginLibrary; +use crate::plugin::process::{AudioBuffers, Event, ProcessScope, TransportState}; +use crate::tests::TestStatus; +use crate::tests::rng::{NoteGenerator, TransportFuzzer, new_prng}; +use anyhow::{Context, Result}; + +const BUFFER_SIZE: u32 = 128; + +/// The test for `PluginTestCase::TransportNull` +pub fn test_transport_null(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..5 { + process.transport().is_freerun = true; + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.audio_buffers().fill_white_noise(&mut prng); + process.run()?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::TransportFuzz` +pub fn test_transport_fuzz(library: &PluginLibrary, plugin_id: &str) -> Result { + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + plugin.on_audio_thread(|plugin| -> Result<()> { + let mut transport_fuzz = TransportFuzzer::new(); + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + for _ in 0..80 { + transport_fuzz.mutate(&mut prng, process.transport()); + + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.audio_buffers().fill_white_noise(&mut prng); + process.run()?; + } + + Ok(()) + })?; + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} + +/// The test for `PluginTestCase::TransportFuzzSampleAccurate` +pub fn test_transport_fuzz_sample_accurate(library: &PluginLibrary, plugin_id: &str) -> Result { + const INTERVALS: &[u32] = &[1000, 100, 1]; + + let mut prng = new_prng(); + + let plugin = library + .create_plugin(plugin_id) + .context("Could not create the plugin instance")?; + plugin.init().context("Error during initialization")?; + + let audio_ports_config = match plugin.get_extension::() { + Some(audio_ports) => audio_ports + .config() + .context("Error while querying 'audio-ports' IO configuration")?, + None => AudioPortConfig::default(), + }; + + let note_ports_config = match plugin.get_extension::() { + Some(note_ports) => note_ports + .config() + .context("Error while querying 'note-ports' IO configuration")?, + None => NotePortConfig::default(), + }; + + for &interval in INTERVALS { + let _span = Span::begin("Interval", record! { interval: interval }); + + plugin + .on_audio_thread(|plugin| -> Result<()> { + let mut note_rng = NoteGenerator::new(¬e_ports_config).with_sample_offset_range(-1..=128); + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, BUFFER_SIZE); + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + + let mut transport_fuzz = TransportFuzzer::new(); + let mut transport_state = TransportState::default(); + let mut transport_start = TransportState::default(); + let mut current_sample = 0; + + for _ in 0..20 { + // reset transport state at the start of each block + process.transport().clone_from(&transport_start); + + // add sample-accurate transport events + while current_sample < BUFFER_SIZE { + // save transport state at the start of the next block + if current_sample + interval >= BUFFER_SIZE { + transport_start = transport_state.clone(); + transport_start.advance((BUFFER_SIZE - current_sample) as i64, process.sample_rate()); + } + + // advance transport state to the event position, mutate it, and add the event + transport_state.advance(interval as i64, process.sample_rate()); + transport_fuzz.mutate(&mut prng, &mut transport_state); + + // this will also send the event at current_sample == 0 + // but that's fine, the plugin should handle that correctly + process.add_events([Event::Transport(transport_state.as_clap_transport(current_sample))]); + current_sample += interval; + } + + current_sample -= BUFFER_SIZE; + + process.audio_buffers().fill_white_noise(&mut prng); + process.add_events(note_rng.generate_events(&mut prng, BUFFER_SIZE)); + process.run()?; + } + + Ok(()) + }) + .with_context(|| { + format!( + "Error during sample-accurate transport test with interval of {} samples", + interval + ) + })?; + } + + plugin.poll_callback(|_| Ok(()))?; + + Ok(TestStatus::Success { details: None }) +} diff --git a/src/tests/plugin_library.rs b/src/tests/plugin_library.rs index ffef82c..97240d6 100644 --- a/src/tests/plugin_library.rs +++ b/src/tests/plugin_library.rs @@ -1,117 +1,95 @@ //! Tests for entire plugin libraries. These are mostly used to test plugin scanning behavior. -use clap::ValueEnum; +use crate::cli::tracing::{Span, record}; +use crate::tests::TestStatus; +use anyhow::Result; +use serde::{Deserialize, Serialize}; use std::path::Path; -use std::process::Command; -use std::time::Duration; - -use super::{TestCase, TestResult}; mod factories; mod preset_discovery; mod scanning; -const SCAN_TIME_LIMIT: Duration = Duration::from_millis(100); - /// Tests for entire CLAP libraries. These are mostly to ensure good plugin scanning practices. See /// the module's heading for more information, and the `description` function below for a /// description of each test case. -#[derive(strum_macros::Display, strum_macros::EnumString, strum_macros::EnumIter)] +#[derive( + strum_macros::Display, + strum_macros::EnumString, + strum_macros::EnumIter, + strum_macros::IntoStaticStr, + Serialize, + Deserialize, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] pub enum PluginLibraryTestCase { - #[strum(serialize = "preset-discovery-crawl")] + QueryNonexistentFactory, + CreateIdWithTrailingGarbage, + ScanRtldNow, + ScanTime, PresetDiscoveryCrawl, - #[strum(serialize = "preset-discovery-descriptor-consistency")] PresetDiscoveryDescriptorConsistency, - #[strum(serialize = "preset-discovery-load")] PresetDiscoveryLoad, - #[strum(serialize = "scan-time")] - ScanTime, - #[strum(serialize = "scan-rtld-now")] - ScanRtldNow, - #[strum(serialize = "query-factory-nonexistent")] - QueryNonexistentFactory, - #[strum(serialize = "create-id-with-trailing-garbage")] - CreateIdWithTrailingGarbage, } -impl<'a> TestCase<'a> for PluginLibraryTestCase { - /// The path to a CLAP plugin library. - type TestArgs = &'a Path; - - fn description(&self) -> String { +impl PluginLibraryTestCase { + pub fn description(&self) -> String { match self { - PluginLibraryTestCase::PresetDiscoveryCrawl => String::from( - "If the plugin supports the preset discovery mechanism, then this test ensures \ - that all of the plugin's declared locations can be indexed successfully.", + Self::PresetDiscoveryCrawl => String::from( + "If the plugin supports the preset discovery mechanism, then this test ensures that all of the \ + plugin's declared locations can be indexed successfully.", ), - PluginLibraryTestCase::PresetDiscoveryDescriptorConsistency => String::from( - "Ensures that all preset provider descriptors from a preset discovery factory \ - match those stored in the providers created by the factory.", + Self::PresetDiscoveryDescriptorConsistency => String::from( + "Ensures that all preset provider descriptors from a preset discovery factory match those stored in \ + the providers created by the factory.", ), - PluginLibraryTestCase::PresetDiscoveryLoad => format!( - "The same as '{}', but also tries to load all found presets for plugins supported \ - the CLAP plugin library. A single plugin instance is reused for loading multiple \ - presets, and the process function is called after loading each preset.", - PluginLibraryTestCase::PresetDiscoveryCrawl + Self::PresetDiscoveryLoad => format!( + "The same as '{}', but also tries to load all found presets for plugins supported the CLAP plugin \ + library. A single plugin instance is reused for loading multiple presets, and the process function \ + is called after loading each preset.", + Self::PresetDiscoveryCrawl ), - PluginLibraryTestCase::ScanTime => format!( + Self::ScanTime => format!( "Checks whether the plugin can be scanned in under {} milliseconds.", - SCAN_TIME_LIMIT.as_millis() + scanning::SCAN_TIME_LIMIT.as_millis() ), - PluginLibraryTestCase::ScanRtldNow => String::from( - "Checks whether the plugin loads correctly when loaded using 'dlopen(..., \ - RTLD_LOCAL | RTLD_NOW)'. Only run on Unix-like platforms.", + Self::ScanRtldNow => String::from( + "Checks whether the plugin loads correctly when loaded using 'dlopen(..., RTLD_LOCAL | RTLD_NOW)'. \ + Only run on Unix-like platforms.", ), - PluginLibraryTestCase::QueryNonexistentFactory => String::from( - "Tries to query a factory from the plugin's entry point with a non-existent ID. \ - This should return a null pointer.", + Self::QueryNonexistentFactory => String::from( + "Tries to query a factory from the plugin's entry point with a non-existent ID. This should return a \ + null pointer.", ), - PluginLibraryTestCase::CreateIdWithTrailingGarbage => String::from( - "Attempts to create a plugin instance using an existing plugin ID with some extra \ - text appended to the end. This should return a null pointer.", + Self::CreateIdWithTrailingGarbage => String::from( + "Attempts to create a plugin instance using an existing plugin ID with some extra text appended to \ + the end. This should return a null pointer.", ), } } - fn set_out_of_process_args(&self, command: &mut Command, library_path: Self::TestArgs) { - let test_name = self.to_string(); - - command - .arg( - crate::validator::SingleTestType::PluginLibrary - .to_possible_value() - .unwrap() - .get_name(), - ) - .arg(library_path) - // This is the plugin ID argument. We could make the `run-single-test` subcommand more - // complicated and have this conditionally be required depending on the test type, but - // this is simpler to reason about. - .arg("(none)") - .arg(test_name); - } - - fn run_in_process(&self, library_path: Self::TestArgs) -> TestResult { - let status = match self { - PluginLibraryTestCase::PresetDiscoveryCrawl => { - preset_discovery::test_crawl(library_path, false) - } - PluginLibraryTestCase::PresetDiscoveryDescriptorConsistency => { - preset_discovery::test_descriptor_consistency(library_path) - } - PluginLibraryTestCase::PresetDiscoveryLoad => { - preset_discovery::test_crawl(library_path, true) - } - PluginLibraryTestCase::ScanTime => scanning::test_scan_time(library_path), - PluginLibraryTestCase::ScanRtldNow => scanning::test_scan_rtld_now(library_path), - PluginLibraryTestCase::QueryNonexistentFactory => { - factories::test_query_nonexistent_factory(library_path) - } - PluginLibraryTestCase::CreateIdWithTrailingGarbage => { - factories::test_create_id_with_trailing_garbage(library_path) - } - }; + pub fn run(&self, library_path: &Path) -> Result { + let _span = Span::begin( + self.into(), + record! { + library_path: library_path.display().to_string() + }, + ); - self.create_result(status) + match self { + Self::PresetDiscoveryCrawl => preset_discovery::test_crawl(library_path, false), + Self::PresetDiscoveryDescriptorConsistency => preset_discovery::test_descriptor_consistency(library_path), + Self::PresetDiscoveryLoad => preset_discovery::test_crawl(library_path, true), + Self::ScanTime => scanning::test_scan_time(library_path), + Self::ScanRtldNow => scanning::test_scan_rtld_now(library_path), + Self::QueryNonexistentFactory => factories::test_query_nonexistent_factory(library_path), + Self::CreateIdWithTrailingGarbage => factories::test_create_id_with_trailing_garbage(library_path), + } } } diff --git a/src/tests/plugin_library/factories.rs b/src/tests/plugin_library/factories.rs index ecfefef..95869aa 100644 --- a/src/tests/plugin_library/factories.rs +++ b/src/tests/plugin_library/factories.rs @@ -1,43 +1,41 @@ //! Tests interacting with the plugin's factories. +use crate::plugin::library::PluginLibrary; +use crate::tests::TestStatus; +use crate::tests::rng::new_prng; use anyhow::{Context, Result}; use clap_sys::version::clap_version_is_compatible; +use rand::Rng; use std::path::Path; -use crate::plugin::host::Host; -use crate::plugin::library::PluginLibrary; -use crate::tests::TestStatus; - /// The test for `PluginLibraryTestCase::QueryNonexistentFactory`. pub fn test_query_nonexistent_factory(library_path: &Path) -> Result { - let library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; + let library = + PluginLibrary::load(library_path).with_context(|| format!("Could not load '{}'", library_path.display()))?; - // This should be actually random instead of using a fixed seed like the other tests. This - // factory ID may not be used by anything. - let nonexistent_factory_id = format!("foo-factory-{}", rand::random::()); - let nonexistent_factory_exists = library.factory_exists(&nonexistent_factory_id); + let mut prng = new_prng(); + for _ in 0..10 { + let factory_id = format!("foo-factory-{}", prng.next_u64()); + let factory_exists = library.factory_exists(&factory_id); - // Since this factory doesn't exist, the plugin should always return a null pointer. - if nonexistent_factory_exists { - anyhow::bail!( - "Querying a factory with the non-existent factory ID '{nonexistent_factory_id} should \ - return a null pointer, but the plugin returned a non-null pointer instead. The \ - plugin may be unconditionally returning the plugin factory." - ); - } else { - Ok(TestStatus::Success { details: None }) + if factory_exists { + anyhow::bail!( + "Querying a factory with the non-existent factory ID '{factory_id}' should return a null pointer, but \ + the plugin returned a non-null pointer instead. The plugin may be unconditionally returning the \ + plugin factory." + ); + } } + + Ok(TestStatus::Success { details: None }) } /// The test for `PluginLibraryTestCase::CreateIdWithTrailingGarbage`. pub fn test_create_id_with_trailing_garbage(library_path: &Path) -> Result { - let library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; + let library = + PluginLibrary::load(library_path).with_context(|| format!("Could not load '{}'", library_path.display()))?; - let metadata = library - .metadata() - .context("Could not query the plugin's metadata")?; + let metadata = library.metadata().context("Could not query the plugin's metadata")?; if !clap_version_is_compatible(metadata.clap_version()) { return Ok(TestStatus::Skipped { details: Some(format!( @@ -68,28 +66,26 @@ pub fn test_create_id_with_trailing_garbage(library_path: &Path) -> Result { return Ok(TestStatus::Skipped { details: Some(String::from( - "All of the coolest plugins already exists. In other words, could not \ - come up a fake unused plugin ID.", + "All of the coolest plugins already exists. In other words, could not come up a fake \ + unused plugin ID.", )), - }) + }); } } } None => { return Ok(TestStatus::Skipped { - details: Some(String::from( - "The plugin library does not expose any plugins", - )), - }) + details: Some(String::from("The plugin library does not expose any plugins")), + }); } }; // This should return an error/null-pointer instead of actually instantiating a // plugin - if library.create_plugin(&fake_plugin_id, Host::new()).is_ok() { + if library.create_plugin(&fake_plugin_id).is_ok() { anyhow::bail!( - "Creating a plugin instance with a non-existent plugin ID '{fake_plugin_id}' should \ - return a null pointer, but it did not." + "Creating a plugin instance with a non-existent plugin ID '{fake_plugin_id}' should return a null \ + pointer, but it did not." ); } else { Ok(TestStatus::Success { details: None }) diff --git a/src/tests/plugin_library/preset_discovery.rs b/src/tests/plugin_library/preset_discovery.rs index 10a01d1..27cf549 100644 --- a/src/tests/plugin_library/preset_discovery.rs +++ b/src/tests/plugin_library/preset_discovery.rs @@ -1,31 +1,24 @@ //! Tests involving the preset discovery factory. -use anyhow::{Context, Result}; -use clap_sys::factory::draft::preset_discovery::CLAP_PRESET_DISCOVERY_FACTORY_ID; -use std::collections::BTreeMap; -use std::path::Path; - use crate::plugin::ext::audio_ports::AudioPorts; use crate::plugin::ext::preset_load::PresetLoad; -use crate::plugin::ext::Extension; -use crate::plugin::host::Host; -use crate::plugin::instance::process::ProcessConfig; use crate::plugin::library::PluginLibrary; use crate::plugin::preset_discovery::{LocationValue, PluginAbi, Preset, PresetFile}; -use crate::tests::plugin::ProcessingTest; +use crate::plugin::process::{AudioBuffers, ProcessScope}; use crate::tests::TestStatus; +use anyhow::{Context, Result}; +use clap_sys::factory::preset_discovery::CLAP_PRESET_DISCOVERY_FACTORY_ID; +use std::collections::BTreeMap; +use std::path::Path; // TODO: Test for duplicate locations and soundpacks in declared data across all providers -/// The fixed buffer size to use for these tests. -const BUFFER_SIZE: usize = 512; - /// The test for `PluginLibraryTestCase::PresetDiscoveryCrawl`. Makes sure that all of a plugin's /// reported preset locations can be crawled successfully. If `load_presets` is enabled, then the /// crawled presets are also loaded. pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result { - let library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; + let library = + PluginLibrary::load(library_path).with_context(|| format!("Could not load '{}'", library_path.display()))?; let preset_discovery_factory = match library.preset_discovery_factory() { Ok(preset_discovery_factory) => preset_discovery_factory, Err(_) => { @@ -34,7 +27,7 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result "The plugin does not implement the '{}' factory.", CLAP_PRESET_DISCOVERY_FACTORY_ID.to_str().unwrap(), )), - }) + }); } }; @@ -47,17 +40,12 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result for provider_metadata in metadata { let provider = preset_discovery_factory .create_provider(&provider_metadata) - .with_context(|| { - format!( - "Could not create the provider with ID '{}'", - provider_metadata.id - ) - })?; + .with_context(|| format!("Could not create the provider with ID '{}'", provider_metadata.id))?; for location in &provider.declared_data().locations { let presets = provider.crawl_location(location).with_context(|| { format!( - "Error occurred while crawling presets for the location '{}' with {} using \ - provider '{}' with ID '{}'", + "Error occurred while crawling presets for the location '{}' with {} using provider '{}' with ID \ + '{}'", location.name, location.value, provider_metadata.name, provider_metadata.id, ) })?; @@ -79,27 +67,25 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result // Stores `PresetFile`s with their associated locations for all CLAP plugin IDs in // `found_presets` - let mut loadable_presets_by_plugin_id: BTreeMap> = - BTreeMap::new(); - let mut maybe_add_preset = - |location: &LocationValue, load_key: Option, preset: Preset| { - for plugin_id in &preset.plugin_ids { - if plugin_id.abi == PluginAbi::Clap { - if !loadable_presets_by_plugin_id.contains_key(&plugin_id.id) { - loadable_presets_by_plugin_id.insert(plugin_id.id.clone(), Vec::new()); - } - - loadable_presets_by_plugin_id - .get_mut(&plugin_id.id) - .unwrap() - .push(LoadablePreset { - location: location.clone(), - load_key: load_key.clone(), - preset: preset.clone(), - }) + let mut loadable_presets_by_plugin_id: BTreeMap> = BTreeMap::new(); + let mut maybe_add_preset = |location: &LocationValue, load_key: Option, preset: Preset| { + for plugin_id in &preset.plugin_ids { + if plugin_id.abi == PluginAbi::Clap { + if !loadable_presets_by_plugin_id.contains_key(&plugin_id.id) { + loadable_presets_by_plugin_id.insert(plugin_id.id.clone(), Vec::new()); } + + loadable_presets_by_plugin_id + .get_mut(&plugin_id.id) + .unwrap() + .push(LoadablePreset { + location: location.clone(), + load_key: load_key.clone(), + preset: preset.clone(), + }) } - }; + } + }; for (location, preset_file) in found_presets { match preset_file { @@ -115,9 +101,8 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result // With everything indexed, we can try loading these presets. We'll reuse one plugin // instance per plugin. for (plugin_id, presets) in loadable_presets_by_plugin_id { - let host = Host::new(); let plugin = library - .create_plugin(&plugin_id, host.clone()) + .create_plugin(&plugin_id) .with_context(|| format!("Could not create a plugin instance for '{plugin_id}'"))?; plugin .init() @@ -128,52 +113,45 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result None => { return Ok(TestStatus::Skipped { details: Some(format!( - "'{}' does not implement the '{}' extension.", + "'{}' does not implement the 'preset-load' extension.", plugin_id, - PresetLoad::EXTENSION_ID.to_str().unwrap(), )), - }) + }); } }; // We'll try to run some audio through the plugin to make sure the preset change was // successful, but it doesn't matter if the plugin doesn't have any audio ports let audio_ports = plugin.get_extension::(); - host.handle_callbacks_once(); + plugin.poll_callback(|_| Ok(()))?; let audio_ports_config = audio_ports .map(|ports| ports.config()) .transpose() - .context("Could not fetch the plugin's audio port config")?; - let (mut input_buffers, mut output_buffers) = audio_ports_config - .unwrap_or_default() - .create_buffers(BUFFER_SIZE); + .context("Could not fetch the plugin's audio port config")? + .unwrap_or_default(); - for LoadablePreset { - location, - load_key, - preset, - } in presets - { + let mut audio_buffers = AudioBuffers::new_out_of_place_f32(&audio_ports_config, 512); + + for preset in presets { // TODO: We now always deactivate the plugin before loading presets, but presets can // be loaded at any point, even when the plugin is processing audio. Test // this. let load_result = preset_load - .from_location(&location, load_key.as_deref()) + .load_from_location(&preset.location, preset.load_key.as_deref()) .with_context(|| { format!( "Could not load the preset '{}' for plugin '{}'", - preset.name, plugin_id + preset.preset.name, plugin_id ) }); // In case the plugin uses `clap_host_preset_load::on_error()` to report an error, // we will check that first before making sure the preset loaded correctly. This // might otherwise mask the error message. - host.handle_callbacks_once(); - host.callback_error_check().with_context(|| { + plugin.poll_callback(|_| Ok(())).with_context(|| { format!( "An error occurred while loading the preset '{}' for plugin '{}'", - preset.name, plugin_id + preset.preset.name, plugin_id ) })?; // See above @@ -181,25 +159,23 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result // We'll process a single buffer of silent audio just to make sure everything's // settled in - ProcessingTest::new_out_of_place(&plugin, &mut input_buffers, &mut output_buffers)? - .run_once(ProcessConfig::default(), |_| Ok(())) + plugin + .on_audio_thread(|plugin| { + let mut process = ProcessScope::new(&plugin, &mut audio_buffers)?; + process.run() + }) .with_context(|| { - format!( - "Error while processing an audio buffer after loading a preset for \ - '{plugin_id}'" - ) + format!("Error while processing an audio buffer after loading a preset for '{plugin_id}'") })?; - host.handle_callbacks_once(); - host.callback_error_check().with_context(|| { - format!("An error occured during a host callback made by '{plugin_id}'") - })?; + plugin + .poll_callback(|_| Ok(())) + .with_context(|| format!("An error occured during a host callback made by '{plugin_id}'"))?; } - host.handle_callbacks_once(); - host.callback_error_check().with_context(|| { - format!("An error occured during a host callback made by '{plugin_id}'") - })?; + plugin + .poll_callback(|_| Ok(())) + .with_context(|| format!("An error occured during a host callback made by '{plugin_id}'"))?; } } @@ -209,8 +185,8 @@ pub fn test_crawl(library_path: &Path, load_presets: bool) -> Result /// The test for `PluginLibraryTestCase::PresetDiscoveryDescriptorConsistency`. Verifies that the /// descriptors stored in a plugin's preset providers match those returned by the factory. pub fn test_descriptor_consistency(library_path: &Path) -> Result { - let library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; + let library = + PluginLibrary::load(library_path).with_context(|| format!("Could not load '{}'", library_path.display()))?; let preset_discovery_factory = match library.preset_discovery_factory() { Ok(preset_discovery_factory) => preset_discovery_factory, Err(_) => { @@ -219,7 +195,7 @@ pub fn test_descriptor_consistency(library_path: &Path) -> Result { "The plugin does not implement the '{}' factory.", CLAP_PRESET_DISCOVERY_FACTORY_ID.to_str().unwrap(), )), - }) + }); } }; @@ -229,25 +205,19 @@ pub fn test_descriptor_consistency(library_path: &Path) -> Result { for factory_metadata in metadata { let provider = preset_discovery_factory .create_provider(&factory_metadata) - .with_context(|| { - format!( - "Could not create the provider with ID '{}'", - factory_metadata.id - ) - })?; + .with_context(|| format!("Could not create the provider with ID '{}'", factory_metadata.id))?; + let provider_metadata = provider.descriptor().with_context(|| { format!( - "Could not grab the descriptor from the 'clap_preset_discovery_provider''s 'desc' \ - field for '{}'", - &factory_metadata.id + "Could not grab the descriptor from the 'clap_preset_discovery_provider''s 'desc' field for '{}'", + factory_metadata.id ) })?; if provider_metadata != factory_metadata { anyhow::bail!( - "The 'clap_preset_discovery_provider_descriptor' stored on '{}'s \ - 'clap_preset_discovery_provider' object contains different values than the one \ - returned by the factory.", + "The 'clap_preset_discovery_provider_descriptor' stored on '{}'s 'clap_preset_discovery_provider' \ + object contains different values than the one returned by the factory.", factory_metadata.id ); } diff --git a/src/tests/plugin_library/scanning.rs b/src/tests/plugin_library/scanning.rs index a16c99f..868857d 100644 --- a/src/tests/plugin_library/scanning.rs +++ b/src/tests/plugin_library/scanning.rs @@ -3,12 +3,13 @@ use anyhow::{Context, Result}; use clap_sys::version::clap_version_is_compatible; use std::path::Path; -use std::time::Instant; +use std::time::{Duration, Instant}; -use super::SCAN_TIME_LIMIT; use crate::plugin::library::PluginLibrary; use crate::tests::TestStatus; +pub const SCAN_TIME_LIMIT: Duration = Duration::from_millis(100); + /// The test for `PluginLibraryTestCase::ScanTime`. pub fn test_scan_time(library_path: &Path) -> Result { let test_start = Instant::now(); @@ -16,8 +17,8 @@ pub fn test_scan_time(library_path: &Path) -> Result { { // The library will be unloaded when this object is dropped, so that is part of the // measurement - let library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display())); + let library = + PluginLibrary::load(library_path).with_context(|| format!("Could not load '{}'", library_path.display())); // This goes through all plugins and builds a data structure containing information for all // of those plugins, mimicing most of a DAW's plugin scanning process @@ -47,11 +48,7 @@ pub fn test_scan_time(library_path: &Path) -> Result { details: Some(format!( "The plugin can be scanned in {} {}.", millis, - if millis == 1 { - "millisecond" - } else { - "milliseconds" - } + if millis == 1 { "millisecond" } else { "milliseconds" } )), }) } else { @@ -80,21 +77,14 @@ pub fn test_scan_rtld_now(library_path: &Path) -> Result { .map(libloading::Library::from) .context("Could not load the plugin library using 'RTLD_LOCAL | RTLD_NOW'") }) - .with_context(|| { - format!( - "Could not load '{}' using 'RTLD_NOW", - library_path.display() - ) - })?; + .with_context(|| format!("Could not load '{}' using 'RTLD_NOW", library_path.display()))?; Ok(TestStatus::Success { details: None }) } #[cfg(not(unix))] -pub fn test_scan_rtld_now(library_path: &Path) -> Result { +pub fn test_scan_rtld_now(_: &Path) -> Result { Ok(TestStatus::Skipped { - details: Some(String::from( - "This test is only relevant to Unix-like platforms", - )), + details: Some(String::from("This test is only relevant to Unix-like platforms")), }) } diff --git a/src/tests/rng.rs b/src/tests/rng.rs index be1664c..a37e995 100644 --- a/src/tests/rng.rs +++ b/src/tests/rng.rs @@ -1,67 +1,100 @@ //! Utilities for generating pseudo-random data. -use anyhow::{Context, Result}; -use clap_sys::events::{ - clap_event_header, clap_event_midi, clap_event_note, clap_event_note_expression, - clap_event_param_value, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_CHOKE, - CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_NOTE_EXPRESSION_PRESSURE, - CLAP_NOTE_EXPRESSION_TUNING, CLAP_NOTE_EXPRESSION_VOLUME, -}; -use clap_sys::ext::note_ports::{ - CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI, CLAP_NOTE_DIALECT_MIDI_MPE, -}; -use midi_consts::channel_event as midi; -use rand::Rng; -use rand_pcg::Pcg32; -use std::ops::RangeInclusive; - +use crate::plugin::ext::audio_ports::AudioPortConfig; +use crate::plugin::ext::configurable_audio_ports::{AudioPortsRequest, AudioPortsRequestInfo}; use crate::plugin::ext::note_ports::NotePortConfig; -use crate::plugin::ext::params::ParamInfo; -use crate::plugin::instance::process::{Event, EventQueue}; +use crate::plugin::ext::params::{Param, ParamInfo}; +use crate::plugin::process::{Event, MidiEvent, TransportState}; +use clap_sys::events::*; +use clap_sys::ext::ambisonic::*; +use core::f64; +use rand::seq::{IndexedRandom, IteratorRandom}; +use rand::{Rng, RngExt, SeedableRng}; +use std::ops::RangeInclusive; +use std::ptr::null_mut; /// Create a new pseudo-random number generator with a fixed seed. -pub fn new_prng() -> Pcg32 { - Pcg32::new(1337, 420) +pub fn new_prng() -> rand::rngs::Xoshiro128PlusPlus { + rand::rngs::Xoshiro128PlusPlus::seed_from_u64(0x1337_6767) } /// A random note and MIDI event generator that generates consistent events based on the /// capabilities stored in a [`NotePortConfig`] #[derive(Debug, Clone)] -pub struct NoteGenerator { +pub struct NoteGenerator<'a> { /// The note ports to generate random events for. - config: NotePortConfig, + config: &'a NotePortConfig, + + /// The parameter info to generate random poly modulation and automation events for. + params: Option<&'a ParamInfo>, + /// Only generate consistent events. This prevents things like note off events for notes that /// aren't playing, double note on events, and generating note expressions for notes that aren't /// active. only_consistent_events: bool, - /// Contains the currently playing notes per-port. We'll be nice and not send overlapping notes - /// or note-offs without a corresponding note-on. - /// - /// TODO: Do send overlapping notes with different note IDs if the plugin claims to support it. - active_notes: Vec>, + /// Send events with wildcard values for the note ID, port index, channel, and key. + wildcard_events: bool, + + /// Allow overlapping notes to be sent. + overlapping_events: bool, + + /// The range for the next event's timing relative to the previous event. + /// This will be capped to 0 when generating events + sample_offset_range: RangeInclusive, + + /// Contains the currently playing notes. We'll be nice and not send note-offs without a corresponding note-on or + /// overlapping note-ons if overlapping notes are not supported. + active_notes: Vec, + /// The CLAP note ID for the next note on event. - next_note_id: i32, + next_note_id: u32, } /// A helper to generate random parameter automation and modulation events in a couple different /// ways to stress test a plugin's parameter handling. pub struct ParamFuzzer<'a> { - config: &'a ParamInfo, + /// The parameter info to generate random events for. + pub params: &'a ParamInfo, + + /// Whether to snap generated parameter values to the parameter's minimum or maximum value. + pub snap_to_bounds: bool, + + /// Set parameter cookies to `null` instead of the actual cookie value. + pub no_cookies: bool, + + /// The range for the next event's timing relative to the previous event. + /// This will be capped to 0 when generating events + pub sample_offset_range: RangeInclusive, +} + +/// A helper to generate random transport events in a couple different ways to stress test a plugin's transport handling. +pub struct TransportFuzzer { + probability_change: f64, } /// The description of an active note in the [`NoteGenerator`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Note { - pub key: i16, - pub channel: i16, - pub note_id: i32, + pub key: u8, + pub channel: u8, + pub port: u32, + pub note_id: u32, /// Whether the note has been choked, we can only send this event once per note. pub choked: bool, + /// Whether to set the 'live' flag on events or not. + pub live: bool, +} + +struct NoteFilter { + pub port: Option, + pub channel: Option, + pub key: Option, + pub note_id: Option, } /// The different kinds of events we can generate. The event type chosen depends on the plugin. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NoteEventType { ClapNoteOn, ClapNoteOff, @@ -74,24 +107,132 @@ enum NoteEventType { MidiPitchBend, MidiCc, MidiProgramChange, + ParamValue, + ParamModulation, } -impl NoteGenerator { +impl NoteEventType { + const CLAP_EVENTS: &'static [NoteEventType] = &[ + NoteEventType::ClapNoteOn, + NoteEventType::ClapNoteOff, + NoteEventType::ClapNoteChoke, + NoteEventType::ClapNoteExpression, + ]; + const MIDI_EVENTS: &'static [NoteEventType] = &[ + NoteEventType::MidiNoteOn, + NoteEventType::MidiNoteOff, + NoteEventType::MidiChannelPressure, + NoteEventType::MidiPolyKeyPressure, + NoteEventType::MidiPitchBend, + NoteEventType::MidiCc, + NoteEventType::MidiProgramChange, + ]; + const PARAM_EVENTS: &'static [NoteEventType] = &[NoteEventType::ParamValue, NoteEventType::ParamModulation]; + + /// Get a slice containing the event types supported by a plugin. Returns None if the plugin + /// supports neither CLAP note events nor MIDI. + pub fn supported_types( + supports_clap_note_events: bool, + supports_midi_events: bool, + supports_param_events: bool, + ) -> impl Iterator { + let clap = if supports_clap_note_events { + Self::CLAP_EVENTS + } else { + &[] + }; + let midi = if supports_midi_events { Self::MIDI_EVENTS } else { &[] }; + let param = if supports_param_events { Self::PARAM_EVENTS } else { &[] }; + + clap.iter().chain(midi.iter()).chain(param.iter()).copied() + } +} + +impl Note { + fn random(prng: &mut impl Rng) -> Self { + Note { + port: prng.random_range(0..10), + key: prng.random_range(0..128), + channel: prng.random_range(0..16), + note_id: prng.random_range(0..100), + live: prng.random_bool(0.1), + choked: false, + } + } + + fn matches(&self, filter: &NoteFilter) -> bool { + (filter.port.is_none() || filter.port == Some(self.port)) + && (filter.channel.is_none() || filter.channel == Some(self.channel)) + && (filter.key.is_none() || filter.key == Some(self.key)) + && (filter.note_id.is_none() || filter.note_id == Some(self.note_id)) + } +} + +impl NoteFilter { + fn from_note(note: &Note) -> Self { + NoteFilter { + port: Some(note.port), + channel: Some(note.channel), + key: Some(note.key), + note_id: Some(note.note_id), + } + } + + fn random_wildcard(&self, prng: &mut impl Rng) -> Self { + NoteFilter { + port: if prng.random_bool(0.1) { None } else { self.port }, + channel: if prng.random_bool(0.1) { None } else { self.channel }, + key: if prng.random_bool(0.1) { None } else { self.key }, + note_id: if prng.random_bool(0.1) { None } else { self.note_id }, + } + } + + fn raw_pckn(&self) -> (i16, i16, i16, i32) { + ( + self.port.map(|p| p as i16).unwrap_or(-1), + self.channel.map(|c| c as i16).unwrap_or(-1), + self.key.map(|k| k as i16).unwrap_or(-1), + self.note_id.map(|id| id as i32).unwrap_or(-1), + ) + } +} + +impl<'a> NoteGenerator<'a> { /// Create a new random note generator based on a plugin's note port configuration. By default /// these events are consistent, meaning that there are no things like note offs before a note /// on, duplicate note ons, or note expressions for notes that don't exist. - pub fn new(config: NotePortConfig) -> Self { - let num_inputs = config.inputs.len(); - + pub fn new(config: &'a NotePortConfig) -> Self { NoteGenerator { config, + params: None, + only_consistent_events: true, + overlapping_events: false, + wildcard_events: false, + + // The range for the next event's timing relative to the `current_sample`. This will be + // capped at 0, so there's a ~58% chance the next event occurs on the same time interval as + // the previous event. + sample_offset_range: -6..=5, - active_notes: vec![Vec::new(); num_inputs], + active_notes: vec![], next_note_id: 0, } } + /// Set the range for the next event's timing relative to the previous event. This will be + /// clamped to 0 when generating events. + pub fn with_sample_offset_range(mut self, range: RangeInclusive) -> Self { + self.sample_offset_range = range; + self + } + + /// Set the parameter info to generate random polyphonic automation and modulation events for. + pub fn with_params(mut self, params: &'a ParamInfo) -> Self { + self.params = Some(params); + self + } + /// Allow inconsistent events, like note off events without a corresponding note on and note /// expression events for notes that aren't currently playing. pub fn with_inconsistent_events(mut self) -> Self { @@ -99,317 +240,316 @@ impl NoteGenerator { self } + /// Allow wildcard events, where the note ID, port index, channel, and key can be set to -1. + pub fn with_wildcard_events(mut self) -> Self { + self.wildcard_events = true; + self + } + + /// Allow overlapping notes (notes with different IDs but same key-port-channel triple). + pub fn with_overlapping_notes(mut self) -> Self { + self.overlapping_events = true; + self + } + /// Fill an event queue with random events for the next `num_samples` samples. This does not /// clear the event queue. If the queue was not empty, then this will do a stable sort after - /// inserting _all_ events. If an error was returned, then the queue will not have been sorted. - /// - /// Returns an error if generating random events failed. This can happen if the plugin doesn't - /// support any note event types. - pub fn fill_event_queue( - &mut self, - prng: &mut Pcg32, - queue: &EventQueue, - num_samples: u32, - ) -> Result<()> { - // The range for the next event's timing relative to the `current_sample`. This will be - // capped at 0, so there's a ~58% chance the next event occurs on the same time interval as - // the previous event. - const SAMPLE_OFFSET_RANGE: RangeInclusive = -6..=5; - - let mut events = queue.events.lock(); - let should_sort = !events.is_empty(); - - let mut current_sample = prng.gen_range(SAMPLE_OFFSET_RANGE).max(0) as u32; - while current_sample < num_samples { - events.push(self.generate(prng, current_sample)?); - - current_sample += prng.gen_range(SAMPLE_OFFSET_RANGE).max(0) as u32; - } + /// inserting _all_ events. + pub fn generate_events(&mut self, prng: &mut impl Rng, num_samples: u32) -> Vec { + let mut events = vec![]; + let mut sample = prng.random_range(self.sample_offset_range.clone()).max(0) as u32; + + while sample < num_samples { + let Some(event) = self.generate_event(prng, sample) else { + break; + }; - if should_sort { - events.sort_by_key(|event| event.header().time); + events.push(event); + sample += prng.random_range(self.sample_offset_range.clone()).max(0) as u32; } - Ok(()) + events } /// Generate a random note event for one of the plugin's note ports depending on the port's /// capabilities. Returns an error if the plugin doesn't have any note ports or if the note /// ports don't support either MIDI or CLAP note events. - pub fn generate(&mut self, prng: &mut Pcg32, time_offset: u32) -> Result { + pub fn generate_event(&mut self, prng: &mut impl Rng, time_offset: u32) -> Option { if self.config.inputs.is_empty() { - anyhow::bail!("Cannot generate note events for a plugin with no input note ports."); + return None; } - // We'll ignore the prefered note dialect and pick from all of the supported note dialects. - // The plugin may get a CLAP note on and a MIDI note off if it supports both of those things - let note_port_idx = prng.gen_range(0..self.config.inputs.len()); - let supports_clap_note_events = self.config.inputs[note_port_idx] - .supported_dialects - .contains(&CLAP_NOTE_DIALECT_CLAP); - let supports_midi_events = self.config.inputs[note_port_idx] - .supported_dialects - .contains(&CLAP_NOTE_DIALECT_MIDI) - || self.config.inputs[note_port_idx] - .supported_dialects - .contains(&CLAP_NOTE_DIALECT_MIDI_MPE); - let possible_events = - NoteEventType::supported_types(supports_clap_note_events, supports_midi_events) - .with_context(|| { - format!( - "Note input port {note_port_idx} supports neither CLAP note events nor \ - MIDI. This is technically allowed, but few hosts will be able to \ - interact with the plugin." - ) - })?; + let note_port_idx = prng.random_range(0..self.config.inputs.len()); // We could do this in a smarter way to avoid generating impossible event types (like a note // off when there are no active notes), but this should work fine. for _ in 0..1024 { - let event_type = prng.sample(rand::distributions::Slice::new(possible_events).unwrap()); + // We'll ignore the prefered note dialect and pick from all of the supported note dialects. + // The plugin may get a CLAP note on and a MIDI note off if it supports both of those things + let event_type = NoteEventType::supported_types( + self.config.inputs[note_port_idx].supports_clap(), + self.config.inputs[note_port_idx].supports_midi(), + self.params.is_some(), + ) + .choose(prng)?; + match event_type { NoteEventType::ClapNoteOn => { let note = if self.only_consistent_events { - let key = prng.gen_range(0..128); - let channel = prng.gen_range(0..16); - let note_id = self.next_note_id; let note = Note { - key, - channel, - note_id, - choked: false, + note_id: self.next_note_id, + port: note_port_idx as u32, + ..Note::random(prng) }; - if self.active_notes[note_port_idx].contains(¬e) { + + if !self.overlapping_events + && self + .active_notes + .iter() + .any(|n| n.port == note.port && n.channel == note.channel && n.key == note.key) + { continue; } - self.active_notes[note_port_idx].push(note); + + self.active_notes.push(note); self.next_note_id = self.next_note_id.wrapping_add(1); note } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let velocity = prng.gen_range(0.0..=1.0); - return Ok(Event::Note(clap_event_note { + let velocity = prng.random_range(0.0..=1.0); + return Some(Event::Note(clap_event_note { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_NOTE_ON, - // TODO: There's a live flag here, should we also randomize this? - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, - note_id: note.note_id, - port_index: note_port_idx as i16, - channel: note.channel, - key: note.key, + note_id: note.note_id as i32, + port_index: note.port as i16, + channel: note.channel as i16, + key: note.key as i16, velocity, })); } NoteEventType::ClapNoteOff => { let note = if self.only_consistent_events { - if self.active_notes[note_port_idx].is_empty() { - continue; + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, } - - let note_idx = prng.gen_range(0..self.active_notes[note_port_idx].len()); - self.active_notes[note_port_idx].remove(note_idx) } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let velocity = prng.gen_range(0.0..=1.0); - return Ok(Event::Note(clap_event_note { + let filter = if self.wildcard_events { + NoteFilter::from_note(¬e).random_wildcard(prng) + } else { + NoteFilter::from_note(¬e) + }; + + self.active_notes.retain(|n| !n.matches(&filter)); + + let velocity = prng.random_range(0.0..=1.0); + let (port_index, channel, key, note_id) = filter.raw_pckn(); + return Some(Event::Note(clap_event_note { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_NOTE_OFF, - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, - note_id: note.note_id, - port_index: note_port_idx as i16, - channel: note.channel, - key: note.key, + note_id, + port_index, + channel, + key, velocity, })); } NoteEventType::ClapNoteChoke => { let note = if self.only_consistent_events { - if self.active_notes[note_port_idx].is_empty() { - continue; + match self.active_notes.choose(prng) { + Some(note) if !note.choked => *note, + _ => continue, } - - // A note can only be choked once - let note_idx = prng.gen_range(0..self.active_notes[note_port_idx].len()); - let note = &mut self.active_notes[note_port_idx][note_idx]; - if note.choked { - continue; - } - note.choked = true; - - *note } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - // Does a velocity make any sense here? Probably not. - let velocity = prng.gen_range(0.0..=1.0); - return Ok(Event::Note(clap_event_note { + let filter = if self.wildcard_events { + NoteFilter::from_note(¬e).random_wildcard(prng) + } else { + NoteFilter::from_note(¬e) + }; + + for note in self.active_notes.iter_mut() { + if note.matches(&filter) { + note.choked = true; + } + } + + let (port_index, channel, key, note_id) = filter.raw_pckn(); + return Some(Event::Note(clap_event_note { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_NOTE_CHOKE, - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, - note_id: note.note_id, - port_index: note_port_idx as i16, - channel: note.channel, - key: note.key, - velocity, + note_id, + port_index, + channel, + key, + velocity: f64::NAN, })); } + NoteEventType::ClapNoteExpression => { let note = if self.only_consistent_events { - if self.active_notes[note_port_idx].is_empty() { - continue; + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, } - - let note_idx = prng.gen_range(0..self.active_notes[note_port_idx].len()); - self.active_notes[note_port_idx][note_idx] } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let expression_id = - prng.gen_range(CLAP_NOTE_EXPRESSION_VOLUME..=CLAP_NOTE_EXPRESSION_PRESSURE); + let filter = if self.wildcard_events { + NoteFilter::from_note(¬e).random_wildcard(prng) + } else { + NoteFilter::from_note(¬e) + }; + + let expression_id = prng.random_range(CLAP_NOTE_EXPRESSION_VOLUME..=CLAP_NOTE_EXPRESSION_PRESSURE); let value_range = match expression_id { CLAP_NOTE_EXPRESSION_VOLUME => 0.0..=4.0, CLAP_NOTE_EXPRESSION_TUNING => -128.0..=128.0, _ => 0.0..=1.0, }; - let value = prng.gen_range(value_range); + let value = prng.random_range(value_range); + let (port_index, channel, key, note_id) = filter.raw_pckn(); - return Ok(Event::NoteExpression(clap_event_note_expression { + return Some(Event::NoteExpression(clap_event_note_expression { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_NOTE_CHOKE, - flags: 0, + type_: CLAP_EVENT_NOTE_EXPRESSION, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, expression_id, - note_id: note.note_id, - port_index: note_port_idx as i16, - channel: note.channel, - key: note.key, + note_id, + port_index, + channel, + key, value, })); } NoteEventType::MidiNoteOn => { let note = if self.only_consistent_events { - let key = prng.gen_range(0..128); - let channel = prng.gen_range(0..16); - let note_id = self.next_note_id; let note = Note { - key, - channel, - note_id, - choked: false, + note_id: self.next_note_id, + port: note_port_idx as u32, + ..Note::random(prng) }; - if self.active_notes[note_port_idx].contains(¬e) { + + if !self.overlapping_events + && self + .active_notes + .iter() + .any(|n| n.port == note.port && n.channel == note.channel && n.key == note.key) + { continue; } - self.active_notes[note_port_idx].push(note); + + self.active_notes.push(note); self.next_note_id = self.next_note_id.wrapping_add(1); note } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let velocity = prng.gen_range(0.0..=1.0); - return Ok(Event::Midi(clap_event_midi { + let velocity = prng.random_range(0.0..=1.0f32); + let velocity = (velocity * 127.0).round().clamp(0.0, 127.0) as u8; + + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_MIDI, - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, port_index: note_port_idx as u16, - data: [ - midi::NOTE_ON | note.channel as u8, - note.key as u8, - (velocity * 127.0f32).round().clamp(0.0, 127.0) as u8, - ], + data: MidiEvent::NoteOn { + key: note.key, + channel: note.channel, + velocity, + } + .into_bytes(), })); } NoteEventType::MidiNoteOff => { let note = if self.only_consistent_events { - if self.active_notes[note_port_idx].is_empty() { - continue; + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, } - - let note_idx = prng.gen_range(0..self.active_notes[note_port_idx].len()); - self.active_notes[note_port_idx].remove(note_idx) } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let velocity = prng.gen_range(0.0..=1.0); - return Ok(Event::Midi(clap_event_midi { + self.active_notes + .retain(|n| n.port != note.port || n.channel != note.channel || n.key != note.key); + + let velocity = prng.random_range(0.0..=1.0f32); + let velocity = (velocity * 127.0).round().clamp(0.0, 127.0) as u8; + + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_MIDI, - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, port_index: note_port_idx as u16, - data: [ - midi::NOTE_OFF | note.channel as u8, - note.key as u8, - (velocity * 127.0f32).round().clamp(0.0, 127.0) as u8, - ], + data: MidiEvent::NoteOff { + key: note.key, + channel: note.channel, + velocity, + } + .into_bytes(), })); } NoteEventType::MidiChannelPressure => { - let channel = prng.gen_range(0..16); - let pressure = prng.gen_range(0..128); - return Ok(Event::Midi(clap_event_midi { + let channel = prng.random_range(0..16); + let pressure = prng.random_range(0..128); + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, @@ -418,49 +558,48 @@ impl NoteGenerator { flags: 0, }, port_index: note_port_idx as u16, - data: [midi::CHANNEL_KEY_PRESSURE | channel, pressure, 0], + data: MidiEvent::ChannelPressure { + pressure, + channel: channel as u8, + } + .into_bytes(), })); } NoteEventType::MidiPolyKeyPressure => { let note = if self.only_consistent_events { - if self.active_notes[note_port_idx].is_empty() { - continue; + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, } - - let note_idx = prng.gen_range(0..self.active_notes[note_port_idx].len()); - self.active_notes[note_port_idx][note_idx] } else { Note { - key: prng.gen_range(0..128), - channel: prng.gen_range(0..16), - note_id: prng.gen_range(0..100), - choked: false, + port: note_port_idx as u32, + ..Note::random(prng) } }; - let pressure = prng.gen_range(0..128); - return Ok(Event::Midi(clap_event_midi { + let pressure = prng.random_range(0..128); + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, space_id: CLAP_CORE_EVENT_SPACE_ID, type_: CLAP_EVENT_MIDI, - flags: 0, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, }, port_index: note_port_idx as u16, - data: [ - midi::POLYPHONIC_KEY_PRESSURE | note.channel as u8, - note.key as u8, + data: MidiEvent::NotePressure { + key: note.key, + channel: note.channel, pressure, - ], + } + .into_bytes(), })); } NoteEventType::MidiPitchBend => { - // May as well just generate the two bytes directly instead of doing fancy things - let channel = prng.gen_range(0..16); - let byte1 = prng.gen_range(0..128); - let byte2 = prng.gen_range(0..128); - return Ok(Event::Midi(clap_event_midi { + let channel = prng.random_range(0..16); + let value = prng.random_range(-1.0..=1.0); + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, @@ -469,14 +608,18 @@ impl NoteGenerator { flags: 0, }, port_index: note_port_idx as u16, - data: [midi::PITCH_BEND_CHANGE | channel, byte1, byte2], + data: MidiEvent::PitchBend { + value, + channel: channel as u8, + } + .into_bytes(), })); } NoteEventType::MidiCc => { - let channel = prng.gen_range(0..16); - let cc = prng.gen_range(0..128); - let value = prng.gen_range(0..128); - return Ok(Event::Midi(clap_event_midi { + let channel = prng.random_range(0..16); + let param = prng.random_range(0..128); + let value = prng.random_range(0..128); + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, @@ -485,13 +628,18 @@ impl NoteGenerator { flags: 0, }, port_index: note_port_idx as u16, - data: [midi::CONTROL_CHANGE | channel, cc, value], + data: MidiEvent::ControlChange { + param, + value, + channel: channel as u8, + } + .into_bytes(), })); } NoteEventType::MidiProgramChange => { - let channel = prng.gen_range(0..16); - let program_number = prng.gen_range(0..128); - return Ok(Event::Midi(clap_event_midi { + let channel = prng.random_range(0..16); + let program_number = prng.random_range(0..128); + return Some(Event::Midi(clap_event_midi { header: clap_event_header { size: std::mem::size_of::() as u32, time: time_offset, @@ -500,118 +648,524 @@ impl NoteGenerator { flags: 0, }, port_index: note_port_idx as u16, - data: [midi::PROGRAM_CHANGE | channel, program_number, 0], + data: MidiEvent::ProgramChange { + program: program_number, + channel: channel as u8, + } + .into_bytes(), + })); + } + NoteEventType::ParamValue => { + let Some(params) = self.params else { + continue; + }; + + let Some((param_id, param)) = params + .iter() + .filter(|(_, param)| !param.is_readonly() && !param.is_hidden() && param.is_poly_automatable()) + .choose(prng) + else { + continue; + }; + + let note = if self.only_consistent_events { + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, + } + } else { + Note { + port: note_port_idx as u32, + ..Note::random(prng) + } + }; + + let filter = if self.wildcard_events { + NoteFilter::from_note(¬e).random_wildcard(prng) + } else { + NoteFilter::from_note(¬e) + }; + + let (port_index, channel, key, note_id) = filter.raw_pckn(); + let value = ParamFuzzer::random_value(param, prng); + + return Some(Event::ParamValue(clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: time_offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, + }, + param_id: *param_id, + cookie: param.cookie.map_or(null_mut(), |x| x.as_ptr()), + note_id, + port_index, + channel, + key, + value, + })); + } + NoteEventType::ParamModulation => { + let Some(params) = self.params else { + continue; + }; + + let Some((param_id, param)) = params + .iter() + .filter(|(_, param)| !param.is_readonly() && !param.is_hidden() && param.is_poly_modulatable()) + .choose(prng) + else { + continue; + }; + + let note = if self.only_consistent_events { + match self.active_notes.choose(prng) { + Some(note) => *note, + _ => continue, + } + } else { + Note { + port: note_port_idx as u32, + ..Note::random(prng) + } + }; + + let filter = if self.wildcard_events { + NoteFilter::from_note(¬e).random_wildcard(prng) + } else { + NoteFilter::from_note(¬e) + }; + + let (port_index, channel, key, note_id) = filter.raw_pckn(); + let value = ParamFuzzer::random_value(param, prng); + + return Some(Event::ParamValue(clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: time_offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: if note.live { CLAP_EVENT_IS_LIVE } else { 0 }, + }, + param_id: *param_id, + cookie: param.cookie.map_or(null_mut(), |x| x.as_ptr()), + note_id, + port_index, + channel, + key, + value, })); } } } - panic!( - "Unable to generate a random note event after 1024 tries, this is a bug in the \ - validator" - ); + panic!("Unable to generate a random note event after 1024 tries"); } -} -impl NoteEventType { - const ALL: &'static [NoteEventType] = &[ - NoteEventType::ClapNoteOn, - NoteEventType::ClapNoteOff, - NoteEventType::ClapNoteChoke, - NoteEventType::ClapNoteExpression, - NoteEventType::MidiNoteOn, - NoteEventType::MidiNoteOff, - NoteEventType::MidiChannelPressure, - NoteEventType::MidiPolyKeyPressure, - NoteEventType::MidiPitchBend, - NoteEventType::MidiCc, - NoteEventType::MidiProgramChange, - ]; - const CLAP_EVENTS: &'static [NoteEventType] = &[ - NoteEventType::ClapNoteOn, - NoteEventType::ClapNoteOff, - NoteEventType::ClapNoteChoke, - NoteEventType::ClapNoteExpression, - ]; - const MIDI_EVENTS: &'static [NoteEventType] = &[ - NoteEventType::MidiNoteOn, - NoteEventType::MidiNoteOff, - NoteEventType::MidiChannelPressure, - NoteEventType::MidiPolyKeyPressure, - NoteEventType::MidiPitchBend, - NoteEventType::MidiCc, - NoteEventType::MidiProgramChange, - ]; + pub fn stop_all_voices(&mut self, time_offset: u32) -> Vec { + let mut events = vec![]; + for note in self.active_notes.drain(..) { + let supports_clap = self.config.inputs[note.port as usize].supports_clap(); - /// Get a slice containing the event types supported by a plugin. Returns None if the plugin - /// supports neither CLAP note events nor MIDI. - pub fn supported_types( - supports_clap_note_events: bool, - supports_midi_events: bool, - ) -> Option<&'static [NoteEventType]> { - if supports_clap_note_events && supports_midi_events { - Some(NoteEventType::ALL) - } else if supports_clap_note_events { - Some(NoteEventType::CLAP_EVENTS) - } else if supports_midi_events { - Some(NoteEventType::MIDI_EVENTS) - } else { - None + if supports_clap { + events.push(Event::Note(clap_event_note { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: time_offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_NOTE_OFF, + flags: 0, + }, + note_id: note.note_id as i32, + port_index: note.port as i16, + channel: note.channel as i16, + key: note.key as i16, + velocity: 0.0, + })); + } else { + events.push(Event::Midi(clap_event_midi { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: time_offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_MIDI, + flags: 0, + }, + port_index: note.port as u16, + data: MidiEvent::NoteOff { + key: note.key, + channel: note.channel, + velocity: 0, + } + .into_bytes(), + })); + } } + + events + } + + pub fn reset(&mut self) { + self.next_note_id = 0; + self.active_notes.clear(); } } impl<'a> ParamFuzzer<'a> { /// Create a new parameter fuzzer. This ignores parameters that are readonly or hidden. - pub fn new(config: &'a ParamInfo) -> Self { - ParamFuzzer { config } + pub fn new(params: &'a ParamInfo) -> Self { + ParamFuzzer { + params, + no_cookies: false, + snap_to_bounds: false, + sample_offset_range: -10..=20, + } + } + + pub fn with_sample_offset_range(mut self, range: RangeInclusive) -> Self { + self.sample_offset_range = range; + self } - // TODO: Modulation and per-{key,channel,port,note_id} modulation - // TODO: Variants similar to `fill_event_queue` from `NoteGenerator` - // TODO: A variant that snaps to the minimum or maximum value + pub fn with_no_cookies(mut self, no_cookies: bool) -> Self { + self.no_cookies = no_cookies; + self + } - /// Randomize all parameters at a certain sample index using **automation**, returning an - /// iterator yielding automation events for all parameters. - pub fn randomize_params_at( - &'a self, - prng: &'a mut Pcg32, - time_offset: u32, - ) -> impl Iterator + 'a { - self.config + pub fn snap_to_bounds(mut self, snap_to_bounds: bool) -> Self { + self.snap_to_bounds = snap_to_bounds; + self + } + + /// Fill an event queue with random parameter change events for the next `num_samples` samples. + /// This does not clear the event queue. If the queue was not empty, then this will do a stable + /// sort after inserting _all_ events. + /// + /// Unlike [`ParamFuzzer::randomize_params_at`], this generates [`Event::ParamMod`] events as well as + /// generating events at random irregular unsynchronized (between different parameters) intervals. + pub fn generate_events(&self, prng: &mut impl Rng, num_samples: u32) -> Vec { + let mut events = vec![]; + let mut sample = prng.random_range(self.sample_offset_range.clone()).max(0) as u32; + while sample < num_samples { + let Some(event) = self.generate_event(prng) else { + break; + }; + + events.push(event); + sample += prng.random_range(self.sample_offset_range.clone()).max(0) as u32; + } + + events + } + + /// Generate a single random parameter change event for one of the plugin's parameters. + pub fn generate_event(&self, prng: &mut impl Rng) -> Option { + let (param_id, param_info) = self + .params .iter() - .filter_map(move |(param_id, param_info)| { - // We can send parameter changes for parameters that are not automatable: - // - // > The host can send live user changes for this parameter regardless of this flag. - if param_info.readonly() || param_info.hidden() { - return None; - } + .filter(|(_, info)| !info.is_readonly() && !info.is_hidden()) + .choose(prng)?; - let value = if param_info.stepped() { - // We already confirmed that the range starts and ends in an integer when - // constructing the parameter info - prng.gen_range(param_info.range.clone()).round() + if !self.snap_to_bounds && param_info.is_modulatable() && prng.random_bool(0.5) { + Some(Event::ParamValue(clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: 0, + }, + param_id: *param_id, + cookie: param_info + .cookie + .filter(|_| !self.no_cookies) + .map_or(null_mut(), |x| x.as_ptr()), + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + value: ParamFuzzer::random_modulation(param_info, prng), + })) + } else { + Some(Event::ParamValue(clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: if param_info.is_automatable() { + 0 + } else { + CLAP_EVENT_IS_LIVE + }, + }, + param_id: *param_id, + cookie: param_info + .cookie + .filter(|_| !self.no_cookies) + .map_or(null_mut(), |x| x.as_ptr()), + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + value: ParamFuzzer::random_value(param_info, prng), + })) + } + } + + /// Randomize _all_ parameters at a certain sample index using **automation**, returning an + /// iterator yielding automation events for all parameters. + pub fn randomize_params_at(&'a self, prng: &'a mut impl Rng, time_offset: u32) -> impl Iterator + 'a { + self.params.iter().filter_map(move |(param_id, param_info)| { + // We can send parameter changes for parameters that are not automatable: + // + // > The host can send live user changes for this parameter regardless of this flag. + if param_info.is_readonly() || param_info.is_hidden() { + return None; + } + + let value = if self.snap_to_bounds { + if prng.random_bool(0.5) { + *param_info.range.start() } else { - prng.gen_range(param_info.range.clone()) + *param_info.range.end() + } + } else { + ParamFuzzer::random_value(param_info, prng) + }; + + Some(Event::ParamValue(clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: time_offset, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: if param_info.is_automatable() { + 0 + } else { + CLAP_EVENT_IS_LIVE + }, + }, + param_id: *param_id, + cookie: param_info + .cookie + .filter(|_| !self.no_cookies) + .map_or(null_mut(), |x| x.as_ptr()), + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + value, + })) + }) + } + + pub fn random_value(param: &Param, prng: &mut impl Rng) -> f64 { + if param.is_stepped() { + // We already confirmed that the range starts and ends in an integer when + // constructing the parameter info + prng.random_range(param.range.clone()).round() + } else { + prng.random_range(param.range.clone()) + } + } + + pub fn random_modulation(param: &Param, prng: &mut impl Rng) -> f64 { + let range = (param.range.end() - param.range.start()).abs() * 0.5; + + if param.is_stepped() { + prng.random_range(-range..=range).round() + } else { + prng.random_range(-range..=range) + } + } +} + +impl TransportFuzzer { + /// Create a new transport fuzzer. + pub fn new() -> Self { + TransportFuzzer { + probability_change: 0.2, + } + } + + /// Mutates an existing transport state. + pub fn mutate(&mut self, prng: &mut impl Rng, transport: &mut TransportState) { + // toggle playback state with 20% probability + if prng.random_bool(self.probability_change) { + transport.is_playing = !transport.is_playing; + } + + // toggle recording state with 20% probability + if prng.random_bool(self.probability_change) { + transport.is_recording = !transport.is_recording; + } + + // change time signature with 20% probability + if prng.random_bool(self.probability_change) { + if prng.random_bool(0.5) { + transport.time_signature = None; + } else { + transport.time_signature = Some((prng.random_range(1..=16), prng.random_range(1..=4))); + } + } + + // change tempo (instanteous) with 20% probability + if prng.random_bool(self.probability_change) { + if prng.random_bool(0.5) { + transport.tempo = None; + } else { + transport.tempo = Some((prng.random_range(40.0..=480.0), 0.0)); + } + } + + // change tempo (ramp) with 40% probability + if let Some((tempo, ramp)) = &mut transport.tempo + && prng.random_bool(self.probability_change) + { + // safeguard to prevent extremely low tempos + if *tempo < 20.0 { + *tempo = 20.0; + *ramp = prng.random_range(0.0..=0.01); + } + + *ramp = prng.random_range(-0.01..=0.01); + } + + // seek to a new position with 10% probability + if prng.random_bool(self.probability_change) { + if prng.random_bool(0.5) { + transport.position_seconds = None; + } else { + transport.position_seconds = Some(prng.random_range(0.0..=60.0)); + } + + if prng.random_bool(0.5) { + transport.position_beats = None; + } else { + transport.position_beats = Some(prng.random_range(0.0..=240.0)); + } + + if prng.random_bool(0.5) { + transport.sample_pos = None; + } else { + // we can only seek forward + transport.sample_pos = Some(transport.sample_pos.unwrap_or(0) + prng.random_range(0..=100_000) as u64); + } + } + + if transport.tempo.is_none() { + transport.position_beats = None; + } + + if !transport.is_playing + && let Some((_, ramp)) = &mut transport.tempo + { + *ramp = 0.0; + } + } +} + +pub fn random_layout_requests(config: &AudioPortConfig, prng: &mut impl Rng) -> Vec> { + fn random_request_info(prng: &mut impl Rng) -> AudioPortsRequestInfo<'static> { + match prng.random_range(0..=4) { + 0 => AudioPortsRequestInfo::Mono, + 1 => AudioPortsRequestInfo::Stereo, + 2 => AudioPortsRequestInfo::Untyped { + channel_count: prng.random_range(1..=16), + }, + 3 => { + const AMBISONIC_ACN_SN3D: clap_ambisonic_config = clap_ambisonic_config { + ordering: CLAP_AMBISONIC_ORDERING_ACN, + normalization: CLAP_AMBISONIC_NORMALIZATION_SN3D, }; - Some(Event::ParamValue(clap_event_param_value { - header: clap_event_header { - size: std::mem::size_of::() as u32, - time: time_offset, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_PARAM_VALUE, - flags: 0, + const AMBISONIC_FUMA_MAXN: clap_ambisonic_config = clap_ambisonic_config { + ordering: CLAP_AMBISONIC_ORDERING_FUMA, + normalization: CLAP_AMBISONIC_NORMALIZATION_MAXN, + }; + + let channel_count = prng.random_range(1..=4u32).pow(2); + let is_acn_sn3d = prng.random_bool(0.5); + + AudioPortsRequestInfo::Ambisonic { + channel_count, + config: if is_acn_sn3d { + &AMBISONIC_ACN_SN3D + } else { + &AMBISONIC_FUMA_MAXN }, - param_id: *param_id, - cookie: param_info.cookie, - note_id: -1, - port_index: -1, - channel: -1, - key: -1, - value, - })) - }) + } + } + _ => { + const SURROUND_MAPS: &[&[u8]] = &[ + &[2], // Mono; FC + &[0, 1], // Stereo; FL FR + &[0, 2, 1], // 3.0; FL FC FR + &[0, 2, 1, 3], // 3.1; FL FC FR LFE + &[0, 2, 1, 8], // 4.0; FL FC FR BC + &[0, 2, 1, 8, 3], // 4.1; FL FC FR BC LFE + &[0, 2, 1, 9, 10], // 5.0; FL FC FR SL SR + &[0, 2, 1, 9, 10, 3], // 5.1; FL FC FR SL SR LFE + &[0, 1, 2], // 3.0; FL FR FC + &[0, 1, 2, 3], // 3.1; FL FR FC LFE + &[0, 1, 2, 8], // 4.0; FL FR FC BC + &[0, 1, 2, 3, 8], // 4.1; FL FR FC LFE BC + &[0, 1, 2, 9, 10], // 5.0; FL FR FC SL SR + &[0, 1, 2, 3, 9, 10], // 5.1; FL FR FC LFE SL SR + ]; + + AudioPortsRequestInfo::Surround { + channel_map: SURROUND_MAPS.choose(prng).unwrap(), + } + } + } + } + + let mut requests = vec![]; + + for index in 0..config.inputs.len() { + if prng.random_bool(0.1) { + // skip request for some inputs + continue; + } + + requests.push(AudioPortsRequest { + is_input: true, + port_index: index as u32, + request_info: random_request_info(prng), + }); + } + + for index in 0..config.outputs.len() { + if prng.random_bool(0.1) { + // skip request for some outputs + continue; + } + + requests.push(AudioPortsRequest { + is_input: false, + port_index: index as u32, + request_info: random_request_info(prng), + }); } + + // throw in random (maybe invalid) requests + while prng.random_bool(0.2) { + let is_input = prng.random_bool(0.5); + let port_index = if is_input { + prng.random_range(config.inputs.len() as u32..=config.inputs.len() as u32 + 10) + } else { + prng.random_range(config.outputs.len() as u32..=config.outputs.len() as u32 + 10) + }; + + requests.push(AudioPortsRequest { + is_input, + port_index, + request_info: random_request_info(prng), + }); + } + + requests } diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 831e5eb..0000000 --- a/src/util.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Miscellaneous functions for data conversions. - -use anyhow::{Context, Result}; -use chrono::{DateTime, TimeZone, Utc}; -use clap_sys::factory::draft::preset_discovery::{clap_timestamp, CLAP_TIMESTAMP_UNKNOWN}; -use std::ffi::CStr; -use std::os::raw::c_char; -use std::path::PathBuf; - -// TODO: Remove these attributes once we start implementing host interfaces - -/// Assert that the specified pointers are non-null. Panics if this is not the case. -macro_rules! check_null_ptr { - ($ptr:expr) => { - if $ptr.is_null() { - panic!("'{}' is not allowed to be a null pointer", stringify!($ptr)) - } - }; - ($($ptrs:expr),*) => { - $($crate::util::check_null_ptr!($ptrs));* - }; -} - -/// Call a CLAP function. This is needed because even though none of CLAP's functions are allowed to -/// be null pointers, people will still use null pointers for some of the function arguments. This -/// also happens in the official `clap-helpers`. As such, these functions are now `Option` -/// optional function pointers in `clap-sys`. This macro asserts that the pointer is not null, and -/// prints a nicely formatted error message containing the struct and funciton name if it is. It -/// also emulates C's syntax for accessing fields struct through a pointer. Except that it uses `=>` -/// instead of `->`. Because that sounds like it would be hilarious. -macro_rules! clap_call { - { $obj_ptr:expr=>$function_name:ident($($args:expr),* $(, )?) } => { - match (*$obj_ptr).$function_name { - Some(function_ptr) => function_ptr($($args),*), - None => panic!("'{}::{}' is a null pointer, but this is not allowed", $crate::util::type_name_of_ptr($obj_ptr), stringify!($function_name)), - } - } -} - -/// [`clap_call!()`], wrapped in an unsafe block. -macro_rules! unsafe_clap_call { - { $($args:tt)* } => { - unsafe { $crate::util::clap_call! { $($args)* } } - } -} - -pub(crate) use check_null_ptr; -pub(crate) use clap_call; -pub(crate) use unsafe_clap_call; - -/// Similar to, [`std::any::type_name_of_val()`], but on stable Rust, and stripping away the pointer -/// part. -#[must_use] -pub fn type_name_of_ptr(_ptr: *const T) -> &'static str { - std::any::type_name::() -} - -/// Convert a `*const c_char` to a `String`. Returns `Ok(None)` if the pointer is a null pointer or -/// if the string is not valid UTF-8. This only returns an error if the string contains invalid -/// UTF-8. -/// -/// # Safety -/// -/// `ptr` should point to a valid null terminated C-string. -pub unsafe fn cstr_ptr_to_string(ptr: *const c_char) -> Result> { - if ptr.is_null() { - return Ok(None); - } - - CStr::from_ptr(ptr) - .to_str() - .map(|str| Some(String::from(str))) - .context("Error while parsing UTF-8") -} - -/// The same as [`cstr_ptr_to_string()`], but it returns an error if the string is empty. -pub unsafe fn cstr_ptr_to_mandatory_string(ptr: *const c_char) -> Result { - match cstr_ptr_to_string(ptr)? { - Some(string) if string.is_empty() => anyhow::bail!("The string is empty."), - Some(string) => Ok(string), - None => anyhow::bail!("The string is a null pointer."), - } -} - -/// The same as [`cstr_ptr_to_string()`], but it treats empty strings as missing. Useful for parsing -/// optional fields from structs. -pub unsafe fn cstr_ptr_to_optional_string(ptr: *const c_char) -> Result> { - match cstr_ptr_to_string(ptr)? { - Some(string) if string.is_empty() => Ok(None), - x => Ok(x), - } -} - -/// Convert a null terminated `*const *const c_char` array to a `Vec`. Returns `None` if the -/// first pointer is a null pointer. Returns an error if any of the strings are not valid UTF-8. -/// -/// # Safety -/// -/// `ptr` should point to a valid null terminated C-string array. -pub unsafe fn cstr_array_to_vec(mut ptr: *const *const c_char) -> Result>> { - if ptr.is_null() { - return Ok(None); - } - - let mut strings = Vec::new(); - while !(*ptr).is_null() { - // We already checked for null pointers, so we can safely unwrap this - strings.push(cstr_ptr_to_string(*ptr)?.unwrap()); - ptr = ptr.offset(1); - } - - Ok(Some(strings)) -} - -/// Convert a `c_char` slice to a `String`. Returns an error if the slice did not contain a null -/// byte, or if the string is not valid UTF-8. -pub fn c_char_slice_to_string(slice: &[c_char]) -> Result { - // `from_bytes_until_nul` is still unstable, so we'll YOLO it for now by checking if the slice - // contains a null byte and then treating it as a pointer if it does - if !slice.contains(&0) { - anyhow::bail!("The string buffer does not contain a null byte.") - } - - unsafe { CStr::from_ptr(slice.as_ptr()) } - .to_str() - .context("Error while parsing UTF-8") - .map(String::from) -} - -/// Convert a `clap_timestamp` to an `Option>`. A value of `CLAP_TIMESTAMP_UNKNOWN` -/// gets translated to `None`. -pub fn parse_timestamp(timestamp: clap_timestamp) -> Result>> { - let parsed = if timestamp == CLAP_TIMESTAMP_UNKNOWN { - None - } else { - Some(match Utc.timestamp_millis_opt(timestamp as i64) { - chrono::LocalResult::Single(datetime) => datetime, - // This shouldn't happen - _ => anyhow::bail!("Could not parse the timestamp."), - }) - }; - - Ok(parsed) -} - -/// [`std::env::temp_dir`], but taking `XDG_RUNTIME_DIR` on Linux into account. -fn temp_dir() -> PathBuf { - #[cfg(all(unix, not(target_os = "macos")))] - if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR").map(PathBuf::from) { - if dir.is_dir() { - return dir; - } - } - - std::env::temp_dir() -} - -/// A temporary directory used by the validator. This is cleared when launching the validator. -pub fn validator_temp_dir() -> PathBuf { - temp_dir().join("clap-validator") -} diff --git a/src/validator.rs b/src/validator.rs index 0345130..acb4ad6 100644 --- a/src/validator.rs +++ b/src/validator.rs @@ -1,487 +1,240 @@ //! The base of the validation framework. This contains utilities for setting up a test case in a //! way that somewhat mimics a real host. +use crate::Verbosity; +use crate::cli::sandbox::{SandboxConfig, SandboxOperation}; +use crate::cli::{Config, IteratorExt, panic_message}; +use crate::commands::validate::ValidatorSettings; +use crate::plugin::library::PluginLibrary; +use crate::tests::{PluginInstanceTestCase, PluginLibraryTestCase, TestCase, TestGroup, TestResult, TestStatus}; use anyhow::{Context, Result}; -use clap::{Args, ValueEnum}; -use clap_sys::version::clap_version_is_compatible; -use rayon::prelude::*; -use regex::{Regex, RegexBuilder}; -use serde::Serialize; +use regex_lite::Regex; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use std::fs; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::PathBuf; +use std::time::{Duration, Instant}; use strum::IntoEnumIterator; -use crate::plugin::library::{PluginLibrary, PluginMetadata}; -use crate::tests::{PluginLibraryTestCase, PluginTestCase, TestCase, TestResult, TestStatus}; -use crate::util; -use crate::Verbosity; - -/// The results of running the validation test suite on one or more plugins. Use the -/// [`tally()`][Self::tally()] method to compute the number of successful and failed tests. -/// -/// Uses `BTreeMap`s purely so the order is stable. -#[derive(Debug, Default, Serialize)] +/// The results of running the validation test suite on one or more plugins. +#[derive(Default, Serialize)] #[serde(rename_all = "kebab-case")] pub struct ValidationResult { - /// A map indexed by plugin library paths containing the results of running the per-plugin - /// library tests on one or more plugin libraries. These tests mainly examine the plugin's - /// scanning behavior. - pub plugin_library_tests: BTreeMap>, - /// A map indexed by plugin IDs containing the results of running the per-plugin tests on one or - /// more plugins. - pub plugin_tests: BTreeMap>, + pub results: Vec, } /// Statistics for the validator. pub struct ValidationTally { /// The number of passed test cases. - pub num_passed: u32, + pub num_passed: usize, /// The number of failed or crashed test cases. - pub num_failed: u32, + pub num_failed: usize, /// The number of skipped test cases. - pub num_skipped: u32, + pub num_skipped: usize, /// The number of test cases resulting in a warning. - pub num_warnings: u32, + pub num_warnings: usize, } -/// Options for the validator. -#[derive(Debug, Args)] -pub struct ValidatorSettings { - /// Paths to one or more plugins that should be validated. - #[arg(required = true)] - pub paths: Vec, - /// Only validate plugins with this ID. - /// - /// If the plugin library contains multiple plugins, then you can pass a single plugin's ID - /// to this option to only validate that plugin. Otherwise all plugins in the library are - /// validated. - #[arg(short = 'i', long)] - pub plugin_id: Option, - /// Print the test output as JSON instead of human readable text. - #[arg(long)] - pub json: bool, - /// Only run the tests that match this case-insensitive regular expression. - #[arg(short = 'f', long)] - pub test_filter: Option, - /// Changes the behavior of -f/--test-filter to skip matching tests instead. - #[arg(short = 'v', long)] - pub invert_filter: bool, - /// When running the validation out-of-process, hide the plugin's output. - /// - /// This can be useful for validating noisy plugins. - #[arg(long)] - pub hide_output: bool, - /// Only show failed tests. - /// - /// This affects both the human readable and the JSON output. - #[arg(long)] - pub only_failed: bool, - /// Run the tests within this process. - /// - /// Tests are normally run in separate processes in case the plugin crashes. Another benefit - /// of the out-of-process validation is that the test always starts from a clean state. - /// Using this option will remove those protections, but in turn the tests may run faster. - #[arg(long)] - pub in_process: bool, - /// Don't run tests in parallel. - /// - /// This will cause the out-of-process tests to be run sequentially. Implied when the - /// --in-process option is used. Can be useful for keeping plugin output in the correct order. - #[arg(long, conflicts_with = "in_process")] - pub no_parallel: bool, -} +impl ValidationResult { + /// Count the number of passing, failing, and skipped tests. + pub fn tally(&self) -> ValidationTally { + let mut num_passed = 0; + let mut num_failed = 0; + let mut num_skipped = 0; + let mut num_warnings = 0; + + for test in &self.results { + match test.status { + TestStatus::Success { .. } => num_passed += 1, + TestStatus::Crashed { .. } | TestStatus::Failed { .. } => num_failed += 1, + TestStatus::Skipped { .. } => num_skipped += 1, + TestStatus::Warning { .. } => num_warnings += 1, + } + } + + ValidationTally { + num_passed, + num_failed, + num_skipped, + num_warnings, + } + } + + pub fn group(&self) -> BTreeMap> { + let mut groups: BTreeMap> = BTreeMap::new(); + + for test in &self.results { + groups.entry(test.test.group()).or_default().push(test.clone()); + } + + groups + } -/// Options for running a single test. This is used for the out-of-process testing method. This -/// option is hidden from the CLI as it's merely an implementation detail. -#[derive(Debug, Args)] -pub struct SingleTestSettings { - pub test_type: SingleTestType, - /// The path to the plugin's library. - pub path: PathBuf, - /// The ID of the plugin within the library that needs to be tested. - pub plugin_id: String, - /// The name of the test to run. [`TestCase`]s can be converted to and from strings to - /// facilitate this. - pub name: String, - /// The name of the file to write the test's JSON result to. This is not done through STDIO - /// because the hosted plugin may also write things there. - #[arg(long)] - pub output_file: PathBuf, + /// Filter the test results using the specified filter function. + pub fn filter(mut self, f: impl FnMut(&TestResult) -> bool) -> Self { + self.results.retain(f); + self + } } -/// The type of test to run when only running a single test. This is only used for out-of-process -/// validation. -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum SingleTestType { - /// A test for an entire plugin library. - /// - /// Used for testing scanning behavior. - PluginLibrary, - /// A test for an individual plugin instance. - Plugin, +impl ValidationTally { + /// Get the total number of tests run. + pub fn total(&self) -> usize { + self.num_passed + self.num_failed + self.num_skipped + self.num_warnings + } } /// Run the validator using the specified settings. Returns an error if any of the plugin paths /// could not loaded, or if the plugin ID filter did not match any plugins. -pub fn validate(verbosity: Verbosity, settings: &ValidatorSettings) -> Result { - // Before doing anything, we need to make sure any temporary artifact files from the previous - // run are cleaned up. These are used for things like state dumps when one of the state tests - // fail. This is allowed to fail since the directory may not exist and even if it does and we - // cannot remove it, then that may not be a problem. - let _ = std::fs::remove_dir_all(util::validator_temp_dir()); - let test_filter_re = settings - .test_filter - .as_deref() - .map(|filter| { - RegexBuilder::new(filter) - .case_insensitive(true) - .build() - .context("The test filter is not a valid regular expression") - }) - .transpose()?; - - // The tests can optionally be run in parallel. This is not the default since some plugins may - // not handle it correctly, event when the plugins are loaded in different processes. It's also - // incompatible with the in-process mode. - // NOTE: The parallel iterators don't preserve the iterator order, so to ensure consistency the - // results are sorted afterwards - // TODO: There doesn't seem to be a way to run rayon iterators on the main thread, so the - // parallel and scalar versions need to be duplicated here. We could also create a single - // threaded shim that implements Rayon's parallel iterator methods, and then branch on the - // places where we create parallel iterators instead. - let mut results = if settings.no_parallel || settings.in_process { - settings - .paths +pub fn validate(verbosity: Verbosity, settings: &ValidatorSettings, config: &Config) -> Result { + let filter_test = { + let test_filter_regexes = settings + .include .iter() - .map(|library_path| { - // We distinguish between two separate classes of tests: tests for an entire plugin - // library, and tests for a single plugin contained witin that library. The former - // group of tests are run first and they only receive the path to the plugin library - // as their argument, while the second class of tests receive an already loaded - // plugin library and a plugin ID as their arugmetns. We'll start with the tests for - // entire plugin libraries so the in-process mode makes a bit more sense. Otherwise - // we would be measuring plugin scanning time on libraries that may still be loaded - // in the process. - let mut plugin_library_tests: BTreeMap> = BTreeMap::new(); - plugin_library_tests.insert( - library_path.clone(), - PluginLibraryTestCase::iter() - .filter(|test| test_filter(test, settings, &test_filter_re)) - .map(|test| run_test(&test, verbosity, settings, library_path)) - .collect::>>()?, - ); - - // And these are the per-plugin instance tests - let plugin_library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; - let plugin_metadata = plugin_library.metadata().with_context(|| { - format!( - "Could not fetch plugin metadata for '{}'", - library_path.display() - ) - })?; - if !clap_version_is_compatible(plugin_metadata.clap_version()) { - log::debug!( - "'{}' uses an unsupported CLAP version ({}.{}.{}), skipping...", - library_path.display(), - plugin_metadata.version.0, - plugin_metadata.version.1, - plugin_metadata.version.2 - ); - - // Since this is a map-reduce, this acts like a continue statement in a loop. We - // could use `.filter_map()` instead but that would only make things more - // complicated - return Ok(ValidationResult::default()); - } - - // We only now know how many tests will be run for this plugin library. We'll count - // the number of plugins that match the filters and then compare that against the - // number of entries in the map to make sure there are no dupli - let plugin_tests: BTreeMap> = plugin_metadata - .plugins - .into_iter() - .filter(|plugin_metadata| plugin_filter(plugin_metadata, settings)) - // We're building a `BTreeMap` containing the results for all plugins in the - // plugin's library - .map(|plugin_metadata| { - Ok(( - plugin_metadata.id.clone(), - PluginTestCase::iter() - .filter(|test| test_filter(test, settings, &test_filter_re)) - .map(|test| { - run_test( - &test, - verbosity, - settings, - (&plugin_library, &plugin_metadata.id), - ) - }) - .collect::>>()?, - )) - }) - .collect::>>()?; - - Ok(ValidationResult { - plugin_library_tests, - plugin_tests, - }) - }) - .reduce(|a, b| { - // Monads galore! The fact that we need to handle errors for plugin tests makes this - // a bit more complicated. - let (a, b) = (a?, b?); - - // In the serial version this could be done when iterating over the plugins, but - // when using iterators you can't do that. But it's still essential to make sure we - // don't test two versionsq of the same plugin. - if a.intersects(&b) { - anyhow::bail!( - "Duplicate plugin ID in validation results. Maybe multiple versions of \ - the same plugin are being validated." - ); - } - - Ok(ValidationResult::union(a, b)) + .map(|x| { + Regex::new(x).with_context(|| format!("Could not parse the test filter regular expression '{}'", x)) }) - .unwrap_or_else(|| Ok(ValidationResult::default())) - } else { - settings - .paths - .par_iter() - .map(|library_path| { - let mut plugin_library_tests: BTreeMap> = BTreeMap::new(); - plugin_library_tests.insert( - library_path.clone(), - PluginLibraryTestCase::iter() - .par_bridge() - .filter(|test| test_filter(test, settings, &test_filter_re)) - .map(|test| run_test(&test, verbosity, settings, library_path)) - .collect::>>()?, - ); - - let plugin_library = PluginLibrary::load(library_path) - .with_context(|| format!("Could not load '{}'", library_path.display()))?; - let plugin_metadata = plugin_library.metadata().with_context(|| { - format!( - "Could not fetch plugin metadata for '{}'", - library_path.display() - ) - })?; - if !clap_version_is_compatible(plugin_metadata.clap_version()) { - log::debug!( - "'{}' uses an unsupported CLAP version ({}.{}.{}), skipping...", - library_path.display(), - plugin_metadata.version.0, - plugin_metadata.version.1, - plugin_metadata.version.2 - ); - - return Ok(ValidationResult::default()); - } + .collect::>>()?; - let plugin_tests: BTreeMap> = plugin_metadata - .plugins - .into_par_iter() - .filter(|plugin_metadata| plugin_filter(plugin_metadata, settings)) - .map(|plugin_metadata| { - Ok(( - plugin_metadata.id.clone(), - PluginTestCase::iter() - .par_bridge() - .filter(|test| test_filter(test, settings, &test_filter_re)) - .map(|test| { - run_test( - &test, - verbosity, - settings, - (&plugin_library, &plugin_metadata.id), - ) - }) - .collect::>>()?, - )) - }) - .collect::>>()?; - - Ok(ValidationResult { - plugin_library_tests, - plugin_tests, - }) + let test_exclude_regexes = settings + .exclude + .iter() + .map(|x| { + Regex::new(x).with_context(|| format!("Could not parse the test exclude regular expression '{}'", x)) }) - .reduce( - || Ok(ValidationResult::default()), - |a, b| { - let (a, b) = (a?, b?); - - if a.intersects(&b) { - anyhow::bail!( - "Duplicate plugin ID in validation results. Maybe multiple versions \ - of the same plugin are being validated." - ); - } + .collect::>>()?; - Ok(ValidationResult::union(a, b)) - }, - ) - }?; - - // The parallel iterators don't preserve order, so this needs to be sorted to make sure the test - // results are always reported in the same order - for tests in results - .plugin_tests - .values_mut() - .chain(results.plugin_library_tests.values_mut()) - { - tests.sort_by(|a, b| Ord::cmp(&a.name, &b.name)); - } + move |id: &str| { + let config_enabled = config.is_test_enabled(id); + let filter_matches = test_filter_regexes.is_empty() || test_filter_regexes.iter().any(|f| f.is_match(id)); + let exclude_matches = test_exclude_regexes.iter().any(|f| f.is_match(id)); - if let Some(plugin_id) = &settings.plugin_id { - if results.plugin_tests.is_empty() { - anyhow::bail!("No plugins matched the plugin ID '{plugin_id}'."); + config_enabled && filter_matches && !exclude_matches } + }; + + let workers = match settings.jobs { + _ if settings.in_process => Some(1), + jobs => jobs, + }; + + // find all tests to run + let tests = discover(&settings.paths, settings.plugin_id.as_deref(), filter_test)?; + + let mut results = tests + .into_iter() + .parallel_map(workers, |test| run_test(verbosity, settings, test)) + .collect::>>()?; + + results.sort_unstable_by(|a, b| a.test.cmp(&b.test)); + + if results.is_empty() { + anyhow::bail!("No tests selected to run"); } - Ok(results) + Ok(ValidationResult { results }) } -/// Run a single test case, and write the result to specified the output file path. This is used for -/// the out-of-process validation mode. -pub fn run_single_test(settings: &SingleTestSettings) -> Result<()> { - let result = match settings.test_type { - SingleTestType::PluginLibrary => { - let test_case = settings - .name - .parse::() - .with_context(|| format!("Unknown test name: {}", &settings.name))?; - - test_case.run_in_process(&settings.path) +/// Run a single test case with the specified settings. +fn run_test(verbosity: Verbosity, settings: &ValidatorSettings, test: TestCase) -> Result { + let start = Instant::now(); + let validation = SandboxedValidation(test.clone()); + let (status, duration) = match settings.in_process { + true => validation.run(), + false => validation + .run_sandboxed(SandboxConfig { + hide_output: settings.hide_output, + verbosity, + timeout: Some(Duration::from_secs(45)), + }) + .unwrap_or_else(|err| { + ( + TestStatus::Crashed { + details: err.to_string(), + }, + start.elapsed(), + ) + }), + }; + + match &status { + TestStatus::Success { .. } => { + log::info!("Test {} completed", test.name()) } - SingleTestType::Plugin => { - let plugin_library = PluginLibrary::load(&settings.path) - .with_context(|| format!("Could not load '{}'", settings.path.display()))?; - let test_case = settings - .name - .parse::() - .with_context(|| format!("Unknown test name: {}", &settings.name))?; - - test_case.run_in_process((&plugin_library, &settings.plugin_id)) + TestStatus::Warning { .. } => { + log::warn!("Test {} completed with a warning", test.name()) } - }; + TestStatus::Failed { .. } => { + log::error!("Test {} failed", test.name()) + } + TestStatus::Crashed { details } => { + log::error!("Test {} crashed: {}", test.name(), details) + } + TestStatus::Skipped { .. } => {} + } - fs::write( - &settings.output_file, - serde_json::to_string(&result).context("Could not format the result as JSON")?, - ) - .with_context(|| { - format!( - "Could not write the result to '{}'", - settings.output_file.display() - ) - }) + Ok(TestResult { test, duration, status }) } -/// The filter function for determining whether or not a test should be run based on the validator's -/// settings settings. -fn test_filter<'a, T: TestCase<'a>>( - test: &T, - settings: &ValidatorSettings, - test_filter_re: &Option, -) -> bool { - let test_name = test.to_string(); - match (&test_filter_re, settings.invert_filter) { - (Some(test_filter_re), false) if !test_filter_re.is_match(&test_name) => false, - (Some(test_filter_re), true) if test_filter_re.is_match(&test_name) => false, - _ => true, - } -} +/// Scan the plugins and construct a list of tests to run based on the specified paths, plugin ID filter, and test filter. +fn discover(paths: &[PathBuf], plugin_id: Option<&str>, filter_test: impl Fn(&str) -> bool) -> Result> { + let mut result = Vec::new(); -/// The filter function for determining whether or not tests should be run for a particular plugin. -fn plugin_filter(plugin_metadata: &PluginMetadata, settings: &ValidatorSettings) -> bool { - // It's possible to filter by plugin ID in case you want to validate a single plugin - // from a plugin library containing multiple plugins - #[allow(clippy::match_like_matches_macro)] - match &settings.plugin_id { - Some(plugin_id) if &plugin_metadata.id != plugin_id => false, - _ => true, - } -} + for path in paths { + let library = PluginLibrary::load(path)?; -/// The filter function for determining whether or not a test should be run based on the validator's -/// settings settings. -fn run_test<'a, T: TestCase<'a>>( - test: &T, - verbosity: Verbosity, - settings: &ValidatorSettings, - args: T::TestArgs, -) -> Result { - if settings.in_process { - Ok(test.run_in_process(args)) - } else { - test.run_out_of_process(args, verbosity, settings.hide_output) - } -} + let metadata = library + .metadata() + .with_context(|| format!("Could not get the plugin metadata for library '{}'", path.display()))?; -impl ValidationResult { - /// Count the number of passing, failing, and skipped tests. - pub fn tally(&self) -> ValidationTally { - let mut num_passed = 0; - let mut num_failed = 0; - let mut num_skipped = 0; - let mut num_warnings = 0; - for test in self - .plugin_library_tests - .values() - .chain(self.plugin_tests.values()) - .flatten() - { - match test.status { - TestStatus::Success { .. } => num_passed += 1, - TestStatus::Crashed { .. } | TestStatus::Failed { .. } => num_failed += 1, - TestStatus::Skipped { .. } => num_skipped += 1, - TestStatus::Warning { .. } => num_warnings += 1, + for test in PluginLibraryTestCase::iter() { + if !filter_test(&test.to_string()) { + continue; } - } - ValidationTally { - num_passed, - num_failed, - num_skipped, - num_warnings, + result.push(TestCase::PluginLibrary { + test, + path: path.clone(), + }); } - } - // Check whether the maps in the object intersect. Useful to ensure that a plugin ID only occurs - // once in the outputs before merging them. - pub fn intersects(&self, other: &Self) -> bool { - for key in other.plugin_library_tests.keys() { - if self.plugin_library_tests.contains_key(key) { - return true; - } - } + for plugin in metadata.plugins { + if plugin_id.as_ref().is_none_or(|id| id == &plugin.id) { + for test in PluginInstanceTestCase::iter() { + if !filter_test(&test.to_string()) { + continue; + } - for key in other.plugin_tests.keys() { - if self.plugin_tests.contains_key(key) { - return true; + result.push(TestCase::PluginInstance { + test, + path: path.clone(), + plugin_id: plugin.id.clone(), + }); + } } } - - false } - /// Merge the results from two validation result objects. If `other` contains a key that also - /// exists in this object, then the version from `other` is used. - pub fn union(mut self, other: Self) -> Self { - self.plugin_library_tests.extend(other.plugin_library_tests); - self.plugin_tests.extend(other.plugin_tests); - - self - } + Ok(result) } -impl ValidationTally { - /// Get the total number of tests run. - pub fn total(&self) -> u32 { - self.num_passed + self.num_failed + self.num_skipped +#[derive(Serialize, Deserialize)] +pub struct SandboxedValidation(TestCase); + +impl SandboxOperation for SandboxedValidation { + const ID: &'static str = "validate"; + type Result = (TestStatus, Duration); + + fn run(&self) -> Self::Result { + let start = Instant::now(); + + let status = match catch_unwind(AssertUnwindSafe(|| self.0.run())) { + Ok(status) => status, + Err(panic) => TestStatus::Crashed { + details: panic_message(&*panic), + }, + }; + + (status, start.elapsed()) } } diff --git a/tests/clack-effect/Cargo.toml b/tests/clack-effect/Cargo.toml new file mode 100644 index 0000000..53e81ca --- /dev/null +++ b/tests/clack-effect/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "clack-effect" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +clack-plugin = { git = "https://github.com/prokopyl/clack", rev = "3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6" } +clack-extensions = { git = "https://github.com/prokopyl/clack", rev = "3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6", features = ["audio-ports", "audio-ports-config", "audio-ports-activation", "configurable-audio-ports", "clack-plugin", "note-ports", "params", "state", "surround", "ambisonic"] } diff --git a/tests/clack-effect/src/lib.rs b/tests/clack-effect/src/lib.rs new file mode 100644 index 0000000..4fa8d2e --- /dev/null +++ b/tests/clack-effect/src/lib.rs @@ -0,0 +1,144 @@ +use crate::params::GainParams; +use clack_extensions::audio_ports::*; +use clack_extensions::params::*; +use clack_extensions::state::PluginState; +use clack_plugin::prelude::*; + +mod params; + +pub struct GainPlugin; + +impl Plugin for GainPlugin { + type AudioProcessor<'a> = GainPluginAudioProcessor<'a>; + type Shared<'a> = GainPluginShared; + type MainThread<'a> = GainPluginMainThread<'a>; + + fn declare_extensions(builder: &mut PluginExtensions, _shared: Option<&GainPluginShared>) { + builder + .register::() + .register::() + .register::(); + } +} + +impl DefaultPluginFactory for GainPlugin { + fn get_descriptor() -> PluginDescriptor { + use clack_plugin::plugin::features::*; + PluginDescriptor::new("org.rust-audio.clack.gain", "Clack Gain Example").with_features([AUDIO_EFFECT, STEREO]) + } + + fn new_shared(_host: HostSharedHandle<'_>) -> Result, PluginError> { + Ok(GainPluginShared { + params: GainParams::new(), + }) + } + + fn new_main_thread<'a>( + host: HostMainThreadHandle<'a>, + shared: &'a Self::Shared<'a>, + ) -> Result, PluginError> { + Ok(Self::MainThread { host, shared }) + } +} + +pub struct GainPluginAudioProcessor<'a> { + shared: &'a GainPluginShared, +} + +impl<'a> PluginAudioProcessor<'a, GainPluginShared, GainPluginMainThread<'a>> for GainPluginAudioProcessor<'a> { + fn activate( + _host: HostAudioProcessorHandle<'a>, + _main_thread: &mut GainPluginMainThread, + shared: &'a GainPluginShared, + _audio_config: PluginAudioConfiguration, + ) -> Result { + Ok(Self { shared }) + } + + fn process(&mut self, _process: Process, mut audio: Audio, events: Events) -> Result { + let mut port_pair = audio + .port_pair(0) + .ok_or(PluginError::Message("No input/output ports found"))?; + + let mut output_channels = port_pair + .channels()? + .into_f32() + .ok_or(PluginError::Message("Expected f32 input/output"))?; + + let mut channel_buffers = [None, None]; + + for (pair, buf) in output_channels.iter_mut().zip(&mut channel_buffers) { + *buf = match pair { + ChannelPair::InputOnly(_) => None, + ChannelPair::OutputOnly(_) => None, + ChannelPair::InPlace(b) => Some(b), + ChannelPair::InputOutput(i, o) => { + o.copy_from_slice(i); + Some(o) + } + } + } + + for event_batch in events.input.batch() { + for event in event_batch.events() { + self.shared.params.handle_event(event) + } + + let volume = self.shared.params.get_volume(); + for buf in channel_buffers.iter_mut().flatten() { + for sample in buf.iter_mut() { + *sample *= volume; + + if sample.is_subnormal() { + *sample = 0.0; + } + } + } + } + + Ok(ProcessStatus::ContinueIfNotQuiet) + } +} + +impl PluginAudioPortsImpl for GainPluginMainThread<'_> { + fn count(&mut self, is_input: bool) -> u32 { + if is_input { 2 } else { 1 } + } + + fn get(&mut self, index: u32, is_input: bool, writer: &mut AudioPortInfoWriter) { + if index == 0 { + writer.set(&AudioPortInfo { + id: ClapId::new(0), + name: b"main", + channel_count: 2, + flags: AudioPortFlags::IS_MAIN, + port_type: Some(AudioPortType::STEREO), + in_place_pair: Some(ClapId::new(0)), + }); + } else if index == 1 && is_input { + writer.set(&AudioPortInfo { + id: ClapId::new(1000), + name: b"sidechain", + channel_count: 2, + flags: AudioPortFlags::empty(), + port_type: Some(AudioPortType::STEREO), + in_place_pair: None, + }); + } + } +} + +pub struct GainPluginShared { + params: GainParams, +} + +impl PluginShared<'_> for GainPluginShared {} + +pub struct GainPluginMainThread<'a> { + host: HostMainThreadHandle<'a>, + shared: &'a GainPluginShared, +} + +impl<'a> PluginMainThread<'a, GainPluginShared> for GainPluginMainThread<'a> {} + +clack_export_entry!(SinglePluginEntry); diff --git a/tests/clack-effect/src/params.rs b/tests/clack-effect/src/params.rs new file mode 100644 index 0000000..56c1b13 --- /dev/null +++ b/tests/clack-effect/src/params.rs @@ -0,0 +1,177 @@ +use crate::{GainPluginAudioProcessor, GainPluginMainThread}; +use clack_extensions::params::*; +use clack_extensions::state::PluginStateImpl; +use clack_plugin::events::spaces::CoreEventSpace; +use clack_plugin::prelude::*; +use clack_plugin::stream::{InputStream, OutputStream}; +use std::ffi::CStr; +use std::fmt::Write as _; +use std::io::{Read, Write as _}; +use std::sync::atomic::{AtomicU32, Ordering}; + +pub const PARAM_VOLUME_ID: ClapId = ClapId::new(1); +pub const PARAM_DRIVE_ID: ClapId = ClapId::new(4); + +const DEFAULT_VOLUME: f32 = 1.0; +const DEFAULT_DRIVE: f32 = 1.0; + +pub struct GainParams { + volume: AtomicF32, + drive: AtomicF32, +} + +impl GainParams { + pub fn new() -> Self { + Self { + volume: AtomicF32::new(DEFAULT_VOLUME), + drive: AtomicF32::new(DEFAULT_DRIVE), + } + } + + #[inline] + pub fn get_volume(&self) -> f32 { + self.volume.load(Ordering::Relaxed) + } + + #[inline] + pub fn get_drive(&self) -> f32 { + self.drive.load(Ordering::Relaxed) + } + + #[inline] + pub fn set_volume(&self, new_volume: f32) { + let new_volume = new_volume.clamp(0., 1.); + self.volume.store(new_volume, Ordering::Relaxed) + } + + #[inline] + pub fn set_drive(&self, new_drive: f32) { + let new_drive = new_drive.clamp(0., 2.); + self.drive.store(new_drive, Ordering::Relaxed) + } + + /// Handles incoming events. + /// + /// If the given event is a matching parameter change event, the volume parameter will be + /// updated accordingly. + pub fn handle_event(&self, event: &UnknownEvent) { + if let Some(CoreEventSpace::ParamValue(event)) = event.as_core_event() { + if event.param_id() == PARAM_VOLUME_ID { + self.set_volume(event.value() as f32) + } else if event.param_id() == PARAM_DRIVE_ID { + self.set_drive(event.value() as f32); + } + } + } +} + +/// Implementation of the State extension. +/// +/// Our state "serialization" is extremely simple and basic: we only have the value of the +/// volume parameter to store, so we just store its bytes (in little-endian) and call it a day. +impl PluginStateImpl for GainPluginMainThread<'_> { + fn save(&mut self, output: &mut OutputStream) -> Result<(), PluginError> { + output.write_all(&self.shared.params.get_volume().to_le_bytes())?; + output.write_all(&self.shared.params.get_drive().to_le_bytes())?; + + Ok(()) + } + + fn load(&mut self, input: &mut InputStream) -> Result<(), PluginError> { + let mut buf = [0; 4]; + input.read_exact(&mut buf)?; + self.shared.params.set_volume(f32::from_le_bytes(buf)); + input.read_exact(&mut buf)?; + self.shared.params.set_drive(f32::from_le_bytes(buf)); + + if let Some(ext) = self.host.get_extension::() { + ext.rescan(&mut self.host, ParamRescanFlags::VALUES); + } + + Ok(()) + } +} + +impl PluginMainThreadParams for GainPluginMainThread<'_> { + fn count(&mut self) -> u32 { + 1 + } + + fn get_info(&mut self, param_index: u32, info: &mut ParamInfoWriter) { + if param_index != 0 { + return; + } + info.set(&ParamInfo { + id: 1.into(), + flags: ParamInfoFlags::IS_AUTOMATABLE, + cookie: Default::default(), + name: b"Volume", + module: b"", + min_value: 0.0, + max_value: 1.0, + default_value: DEFAULT_VOLUME as f64, + }) + } + + fn get_value(&mut self, param_id: ClapId) -> Option { + if param_id == 1 { + Some(self.shared.params.get_volume() as f64) + } else { + None + } + } + + fn value_to_text(&mut self, param_id: ClapId, value: f64, writer: &mut ParamDisplayWriter) -> std::fmt::Result { + if param_id == 1 { + write!(writer, "{0:.2} %", value * 100.0) + } else { + Err(std::fmt::Error) + } + } + + fn text_to_value(&mut self, param_id: ClapId, text: &CStr) -> Option { + let text = text.to_str().ok()?; + if param_id == 1 { + let text = text.strip_suffix('%').unwrap_or(text).trim(); + let percentage: f64 = text.parse().ok()?; + + Some(percentage / 100.0) + } else { + None + } + } + + fn flush(&mut self, input_parameter_changes: &InputEvents, _output_parameter_changes: &mut OutputEvents) { + for event in input_parameter_changes { + self.shared.params.handle_event(event) + } + } +} + +impl PluginAudioProcessorParams for GainPluginAudioProcessor<'_> { + fn flush(&mut self, input_parameter_changes: &InputEvents, _output_parameter_changes: &mut OutputEvents) { + for event in input_parameter_changes { + self.shared.params.handle_event(event) + } + } +} + +/// A small helper to atomically load and store an `f32` value. +struct AtomicF32(AtomicU32); + +impl AtomicF32 { + #[inline] + fn new(value: f32) -> Self { + Self(AtomicU32::new(f32::to_bits(value))) + } + + #[inline] + fn store(&self, value: f32, order: Ordering) { + self.0.store(f32::to_bits(value), order) + } + + #[inline] + fn load(&self, order: Ordering) -> f32 { + f32::from_bits(self.0.load(order)) + } +} diff --git a/tests/clack-synth/Cargo.toml b/tests/clack-synth/Cargo.toml new file mode 100644 index 0000000..d8ea2b1 --- /dev/null +++ b/tests/clack-synth/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "clack-synth" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +clack-plugin = { git = "https://github.com/prokopyl/clack", rev = "3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6" } +clack-extensions = { git = "https://github.com/prokopyl/clack", rev = "3f9b32dc47eeb5a500a9f589e84ee3eb20b44ae6", features = ["audio-ports", "audio-ports-config", "audio-ports-activation", "configurable-audio-ports", "clack-plugin", "note-ports", "params", "state", "surround", "ambisonic"] } diff --git a/tests/clack-synth/src/lib.rs b/tests/clack-synth/src/lib.rs new file mode 100644 index 0000000..37a597a --- /dev/null +++ b/tests/clack-synth/src/lib.rs @@ -0,0 +1,344 @@ +// TODO: we should probably add a negative test (a plugin that fails some of the clap-validator tests) + +use crate::params::{PolySynthParamModulations, PolySynthParams}; +use crate::poly_oscillator::PolyOscillator; +use clack_extensions::audio_ports::*; +use clack_extensions::audio_ports_activation::{ + PluginAudioPortsActivation, PluginAudioPortsActivationImpl, PluginAudioPortsActivationSetImpl, SampleSize, +}; +use clack_extensions::audio_ports_config::{ + AudioPortConfigWriter, AudioPortsConfiguration, MainPortInfo, PluginAudioPortsConfig, PluginAudioPortsConfigImpl, + PluginAudioPortsConfigInfo, PluginAudioPortsConfigInfoImpl, +}; +use clack_extensions::configurable_audio_ports::{ + AudioPortRequest, PluginConfigurableAudioPorts, PluginConfigurableAudioPortsImpl, +}; +use clack_extensions::note_ports::*; +use clack_extensions::params::*; +use clack_extensions::state::PluginState; +use clack_plugin::events::spaces::CoreEventSpace; +use clack_plugin::prelude::*; +use clack_plugin::process::ConstantMask; +use std::f32; +use std::ffi::CString; + +mod oscillator; +mod params; +mod poly_oscillator; + +pub struct PolySynthPlugin; + +impl Plugin for PolySynthPlugin { + type AudioProcessor<'a> = PolySynthAudioProcessor<'a>; + type Shared<'a> = PolySynthPluginShared; + type MainThread<'a> = PolySynthPluginMainThread<'a>; + + fn declare_extensions(builder: &mut PluginExtensions, _shared: Option<&PolySynthPluginShared>) { + builder + .register::() + .register::() + .register::() + .register::() + .register::() + .register::() + .register::() + .register::(); + } +} + +impl DefaultPluginFactory for PolySynthPlugin { + fn get_descriptor() -> PluginDescriptor { + use clack_plugin::plugin::features::*; + + PluginDescriptor::new("org.rust-audio.clack.polysynth", "Clack PolySynth Example").with_features([ + SYNTHESIZER, + MONO, + INSTRUMENT, + ]) + } + + fn new_shared(_host: HostSharedHandle) -> Result { + Ok(PolySynthPluginShared { + params: PolySynthParams::new(), + }) + } + + fn new_main_thread<'a>( + host: HostMainThreadHandle<'a>, + shared: &'a PolySynthPluginShared, + ) -> Result, PluginError> { + Ok(PolySynthPluginMainThread { + host, + shared, + config: ClapId::new(2), + active: true, + }) + } +} + +pub struct PolySynthAudioProcessor<'a> { + channels: u32, + active: bool, + + poly_osc: PolyOscillator, + modulation_values: PolySynthParamModulations, + shared: &'a PolySynthPluginShared, +} + +impl<'a> PluginAudioProcessor<'a, PolySynthPluginShared, PolySynthPluginMainThread<'a>> + for PolySynthAudioProcessor<'a> +{ + fn activate( + _host: HostAudioProcessorHandle<'a>, + main_thread: &mut PolySynthPluginMainThread, + shared: &'a PolySynthPluginShared, + audio_config: PluginAudioConfiguration, + ) -> Result { + Ok(Self { + active: main_thread.active, + channels: main_thread.config.get(), + poly_osc: PolyOscillator::new(16, audio_config.sample_rate as f32), + modulation_values: PolySynthParamModulations::new(), + shared, + }) + } + + fn process(&mut self, _process: Process, mut audio: Audio, events: Events) -> Result { + let mut output_port = audio + .output_port(0) + .ok_or(PluginError::Message("No output port found"))?; + + let mut output_channels = output_port + .channels()? + .into_f32() + .ok_or(PluginError::Message("Expected f32 output"))?; + + let output_buffer = output_channels + .channel_mut(0) + .ok_or(PluginError::Message("Expected at least one channel"))?; + + output_buffer.fill(0.0); + + let mut is_non_silent = false; + for event_batch in events.input.batch() { + for event in event_batch.events() { + self.handle_event(event); + } + + let output_buffer = &mut output_buffer[event_batch.sample_bounds()]; + self.poly_osc.generate_next_samples( + output_buffer, + self.shared.params.get_volume(), + self.modulation_values.volume(), + ); + + is_non_silent |= self.poly_osc.has_active_voices() + } + + // it is legal; when an output port is deactivated, the host must not use its contents + if !self.active { + output_buffer.fill(f32::NAN); + } + + assert!(output_channels.channel_count() == self.channels); + + // Copy the first channel to all other channels for mono output + if output_channels.channel_count() > 1 { + let (first_channel, other_channels) = output_channels.split_at_mut(1); + let first_channel = first_channel.channel(0).unwrap(); + + for other_channel in other_channels { + other_channel.copy_from_slice(first_channel) + } + } + + if !is_non_silent { + audio + .output_port(0) + .unwrap() + .set_constant_mask(ConstantMask::FULLY_CONSTANT); + } + + if self.poly_osc.has_active_voices() { + Ok(ProcessStatus::Continue) + } else { + Ok(ProcessStatus::Sleep) + } + } + + fn stop_processing(&mut self) { + self.poly_osc.stop_all(); + } + + fn reset(&mut self) { + self.poly_osc.stop_all(); + } +} + +impl PolySynthAudioProcessor<'_> { + fn handle_event(&mut self, event: &UnknownEvent) { + match event.as_core_event() { + Some(CoreEventSpace::NoteOn(event)) => self.poly_osc.handle_note_on(event), + Some(CoreEventSpace::NoteOff(event)) => self.poly_osc.handle_note_off(event), + Some(CoreEventSpace::ParamValue(event)) => { + if event.pckn().matches_all() { + self.shared.params.handle_event(event) + } else { + self.poly_osc.handle_param_value(event) + } + } + Some(CoreEventSpace::ParamMod(event)) => { + if event.pckn().matches_all() { + self.modulation_values.handle_event(event) + } else { + self.poly_osc.handle_param_mod(event) + } + } + _ => {} + } + } +} + +impl PluginAudioPortsImpl for PolySynthPluginMainThread<'_> { + fn count(&mut self, is_input: bool) -> u32 { + if is_input { 0 } else { 1 } + } + + fn get(&mut self, index: u32, is_input: bool, writer: &mut AudioPortInfoWriter) { + PluginAudioPortsConfigInfoImpl::get(self, self.config, index, is_input, writer); + } +} + +impl PluginNotePortsImpl for PolySynthPluginMainThread<'_> { + fn count(&mut self, is_input: bool) -> u32 { + if is_input { 1 } else { 0 } + } + + fn get(&mut self, index: u32, is_input: bool, writer: &mut NotePortInfoWriter) { + if is_input && index == 0 { + writer.set(&NotePortInfo { + id: ClapId::new(1), + name: b"main", + preferred_dialect: Some(NoteDialect::Clap), + supported_dialects: NoteDialects::CLAP, + }) + } + } +} + +impl PluginAudioPortsConfigImpl for PolySynthPluginMainThread<'_> { + fn count(&mut self) -> u32 { + 8 + } + + fn get(&mut self, index: u32, writer: &mut AudioPortConfigWriter) { + let channels = index + 1; + writer.write(&AudioPortsConfiguration { + id: ClapId::new(channels), + name: CString::new(format!("Config #{}", channels)).unwrap().as_bytes(), + input_port_count: 0, + output_port_count: 1, + main_input: None, + main_output: Some(MainPortInfo { + channel_count: channels, + port_type: AudioPortType::from_channel_count(channels), + }), + }); + } + + fn select(&mut self, config_id: ClapId) -> Result<(), PluginError> { + if config_id.get() <= 8 { + self.config = config_id; + self.active = true; + Ok(()) + } else { + Err(PluginError::Message("Invalid configuration ID")) + } + } +} + +impl PluginAudioPortsConfigInfoImpl for PolySynthPluginMainThread<'_> { + fn current_config(&mut self) -> Option { + Some(self.config) + } + + fn get(&mut self, config_id: ClapId, index: u32, is_input: bool, writer: &mut AudioPortInfoWriter) { + let channels = config_id.get(); + + if !is_input && index == 0 { + writer.set(&AudioPortInfo { + id: ClapId::new(1), + name: b"main", + channel_count: channels, + flags: AudioPortFlags::IS_MAIN, + port_type: AudioPortType::from_channel_count(channels), + in_place_pair: None, + }); + } + } +} + +impl PluginConfigurableAudioPortsImpl for PolySynthPluginMainThread<'_> { + fn can_apply_configuration(&mut self, requests: &[AudioPortRequest]) -> bool { + matches!(requests.first(), Some(request) if !request.is_input() && request.port_index() == 0 && request.details().channel_count() > 0 && request.details().channel_count() <= 8) + } + + fn apply_configuration(&mut self, requests: &[AudioPortRequest]) -> bool { + match requests.first() { + Some(request) + if !request.is_input() + && request.port_index() == 0 + && request.details().channel_count() > 0 + && request.details().channel_count() <= 8 => + { + self.config = ClapId::new(request.details().channel_count()); + true + } + _ => false, + } + } +} + +impl PluginAudioPortsActivationImpl for PolySynthPluginMainThread<'_> { + fn can_activate_while_processing(&mut self) -> bool { + false + } +} + +impl PluginAudioPortsActivationSetImpl for PolySynthPluginMainThread<'_> { + fn set_active(&mut self, is_input: bool, port_index: u32, is_active: bool, sample_size: SampleSize) -> bool { + if is_input || port_index != 0 { + return false; + } + + if sample_size == SampleSize::Float64 { + return false; + } + + self.active = is_active; + true + } +} + +impl PluginAudioPortsActivationSetImpl for PolySynthAudioProcessor<'_> { + fn set_active(&mut self, _: bool, _: u32, _: bool, _: SampleSize) -> bool { + false + } +} + +pub struct PolySynthPluginShared { + params: PolySynthParams, +} + +impl PluginShared<'_> for PolySynthPluginShared {} + +pub struct PolySynthPluginMainThread<'a> { + host: HostMainThreadHandle<'a>, + shared: &'a PolySynthPluginShared, + config: ClapId, + active: bool, +} + +impl<'a> PluginMainThread<'a, PolySynthPluginShared> for PolySynthPluginMainThread<'a> {} + +clack_export_entry!(SinglePluginEntry); diff --git a/tests/clack-synth/src/oscillator.rs b/tests/clack-synth/src/oscillator.rs new file mode 100644 index 0000000..0261775 --- /dev/null +++ b/tests/clack-synth/src/oscillator.rs @@ -0,0 +1,50 @@ +use std::f32::consts::{PI, TAU}; + +#[derive(Copy, Clone)] +pub struct SquareOscillator { + frequency_to_phase_increment_ratio: f32, + phase_increment: f32, + current_phase: f32, +} + +impl SquareOscillator { + #[inline] + pub fn new(sample_rate: f32) -> Self { + Self { + frequency_to_phase_increment_ratio: 2.0 * PI / sample_rate, + phase_increment: 1.0, + current_phase: 0., + } + } + + #[inline] + pub fn reset(&mut self) { + self.current_phase = 0.; + } + + #[inline] + pub fn set_note_number(&mut self, new_note_number: u8) { + self.set_frequency(440.0 * 2.0f32.powf((new_note_number as f32 - 69.0) / 12.0)); + } + + #[inline] + pub fn set_frequency(&mut self, new_frequency: f32) { + self.phase_increment = new_frequency * self.frequency_to_phase_increment_ratio; + } + + #[inline] + pub fn synth_samples(&mut self, buf: &mut [f32], volume: f32) { + for value in buf { + if self.current_phase <= PI { + *value += volume; + } else { + *value -= volume; + } + + self.current_phase += self.phase_increment; + while self.current_phase > TAU { + self.current_phase -= TAU; + } + } + } +} diff --git a/tests/clack-synth/src/params.rs b/tests/clack-synth/src/params.rs new file mode 100644 index 0000000..568d79d --- /dev/null +++ b/tests/clack-synth/src/params.rs @@ -0,0 +1,185 @@ +//! Contains all types and implementations related to parameter management. + +use crate::{PolySynthAudioProcessor, PolySynthPluginMainThread}; +use clack_extensions::params::*; +use clack_extensions::state::PluginStateImpl; +use clack_plugin::events::event_types::{ParamModEvent, ParamValueEvent}; +use clack_plugin::events::spaces::CoreEventSpace; +use clack_plugin::prelude::*; +use clack_plugin::stream::{InputStream, OutputStream}; +use std::ffi::CStr; +use std::fmt::Write as _; +use std::io::{Read, Write as _}; +use std::sync::atomic::{AtomicU32, Ordering}; + +pub const PARAM_VOLUME_ID: ClapId = ClapId::new(1); + +const DEFAULT_VOLUME: f32 = 0.2; + +pub struct PolySynthParams { + volume: AtomicF32, +} + +impl PolySynthParams { + pub fn new() -> Self { + Self { + volume: AtomicF32::new(DEFAULT_VOLUME), + } + } + + #[inline] + pub fn get_volume(&self) -> f32 { + self.volume.load(Ordering::SeqCst) + } + + #[inline] + pub fn set_volume(&self, new_volume: f32) { + let new_volume = new_volume.clamp(0., 1.); + self.volume.store(new_volume, Ordering::SeqCst) + } + + pub fn handle_event(&self, event: &ParamValueEvent) { + if event.param_id() == PARAM_VOLUME_ID { + self.set_volume(event.value() as f32) + } + } +} + +pub struct PolySynthParamModulations { + volume_mod: f32, +} + +impl PolySynthParamModulations { + pub fn new() -> Self { + Self { volume_mod: 0.0 } + } + + #[inline] + pub fn volume(&self) -> f32 { + self.volume_mod + } + + pub fn handle_event(&mut self, event: &ParamModEvent) { + if event.param_id() == PARAM_VOLUME_ID { + self.volume_mod = event.amount() as f32 + } + } +} + +impl PluginStateImpl for PolySynthPluginMainThread<'_> { + fn save(&mut self, output: &mut OutputStream) -> Result<(), PluginError> { + let volume_param = self.shared.params.get_volume(); + + output.write_all(b"clck")?; + output.write_all(&volume_param.to_le_bytes())?; + Ok(()) + } + + fn load(&mut self, input: &mut InputStream) -> Result<(), PluginError> { + let mut buf = [0; 4]; + input.read_exact(&mut buf)?; + if buf != *b"clck" { + return Err(PluginError::Message("invalid magic header")); + } + + input.read_exact(&mut buf)?; + let volume_value = f32::from_le_bytes(buf); + self.shared.params.set_volume(volume_value); + + if let Some(ext) = self.host.get_extension::() { + ext.rescan(&mut self.host, ParamRescanFlags::VALUES); + } + + Ok(()) + } +} + +impl PluginMainThreadParams for PolySynthPluginMainThread<'_> { + fn count(&mut self) -> u32 { + 1 + } + + fn get_info(&mut self, param_index: u32, info: &mut ParamInfoWriter) { + if param_index == 0 { + info.set(&ParamInfo { + id: PARAM_VOLUME_ID, + flags: ParamInfoFlags::IS_AUTOMATABLE + | ParamInfoFlags::IS_MODULATABLE + | ParamInfoFlags::IS_AUTOMATABLE_PER_CHANNEL + | ParamInfoFlags::IS_AUTOMATABLE_PER_KEY + | ParamInfoFlags::IS_AUTOMATABLE_PER_NOTE_ID + | ParamInfoFlags::IS_MODULATABLE_PER_CHANNEL + | ParamInfoFlags::IS_MODULATABLE_PER_KEY + | ParamInfoFlags::IS_MODULATABLE_PER_NOTE_ID, + cookie: Default::default(), + name: b"Volume", + module: b"", + min_value: 0.0, + max_value: 1.0, + default_value: DEFAULT_VOLUME as f64, + }) + } + } + + fn get_value(&mut self, param_id: ClapId) -> Option { + match param_id { + PARAM_VOLUME_ID => Some(self.shared.params.get_volume() as f64), + _ => None, + } + } + + fn value_to_text(&mut self, param_id: ClapId, value: f64, writer: &mut ParamDisplayWriter) -> std::fmt::Result { + match param_id { + PARAM_VOLUME_ID => write!(writer, "{0:.2} %", value * 100.0), + _ => Err(std::fmt::Error), + } + } + + fn text_to_value(&mut self, param_id: ClapId, text: &CStr) -> Option { + let text = text.to_str().ok()?; + if param_id == PARAM_VOLUME_ID { + let text = text.strip_suffix('%').unwrap_or(text).trim(); + let percentage: f64 = text.parse().ok()?; + + Some(percentage / 100.0) + } else { + None + } + } + + fn flush(&mut self, input_parameter_changes: &InputEvents, _output_parameter_changes: &mut OutputEvents) { + for event in input_parameter_changes { + if let Some(CoreEventSpace::ParamValue(event)) = event.as_core_event() { + self.shared.params.handle_event(event) + } + } + } +} + +impl PluginAudioProcessorParams for PolySynthAudioProcessor<'_> { + fn flush(&mut self, input_parameter_changes: &InputEvents, _output_parameter_changes: &mut OutputEvents) { + for event in input_parameter_changes { + self.handle_event(event) + } + } +} + +/// A small helper to atomically load and store an `f32` value. +struct AtomicF32(AtomicU32); + +impl AtomicF32 { + #[inline] + fn new(value: f32) -> Self { + Self(AtomicU32::new(f32::to_bits(value))) + } + + #[inline] + fn store(&self, value: f32, order: Ordering) { + self.0.store(f32::to_bits(value), order) + } + + #[inline] + fn load(&self, order: Ordering) -> f32 { + f32::from_bits(self.0.load(order)) + } +} diff --git a/tests/clack-synth/src/poly_oscillator.rs b/tests/clack-synth/src/poly_oscillator.rs new file mode 100644 index 0000000..b5d3e3e --- /dev/null +++ b/tests/clack-synth/src/poly_oscillator.rs @@ -0,0 +1,218 @@ +//! Implementations and helpers for our polyphonic oscillator. + +use crate::oscillator::SquareOscillator; +use crate::params::PARAM_VOLUME_ID; +use clack_plugin::events::Match; +use clack_plugin::events::event_types::{NoteOffEvent, NoteOnEvent, ParamModEvent, ParamValueEvent}; + +/// A voice in the polyphonic oscillator. +/// +/// It contains Channel, Key and NoteID information, so that this voice can be found and targeted +/// by polyphonic modulation. +/// +/// It also stores dedicated value and modulation for the polyphonic volume parameter, if the host +/// set it. +#[derive(Copy, Clone)] +struct Voice { + /// The oscillator itself. + oscillator: SquareOscillator, + /// The MIDI channel of the note this voice is playing. + channel: u8, + /// The MIDI number of the note this voice is playing. + key_number: u8, + /// The unique ID of the note this voice is playing. + /// This is None if no ID was assigned to this note by the host. + note_id: Option, + + /// The voice-specific value of the volume parameter. + /// This is None if the host didn't apply polyphonic modulation to this voice. + volume: Option, + + /// The voice-specific modulation amount of the volume parameter. + /// This is None if the host didn't apply polyphonic modulation to this voice. + volume_mod: Option, +} + +impl Voice { + /// Returns whether this voice matches the given matchers. + #[inline] + fn matches(&self, channel: Match, note_key: Match, note_id: Match) -> bool { + if !channel.matches(self.channel) { + return false; + } + + if !note_key.matches(self.key_number) { + return false; + } + + note_id.matches(match self.note_id { + None => Match::All, + Some(id) => Match::Specific(id), + }) + } +} + +/// A simple polyphonic oscillator. +/// +/// It tracks multiple oscillator voices, up to a given maximum. +/// +/// This struct manages the buffer so that active voices are at the beginning, and inactive ones +/// at the end of the buffer. Then, to iterate only on the inactive voices, once can simply iterate +/// on the `0..active_voice_count` range. +pub struct PolyOscillator { + /// The fixed buffer of voices. + voice_buffer: Box<[Voice]>, + /// The number of current + active_voice_count: usize, +} + +impl PolyOscillator { + /// Initializes the oscillators with the given sample rate, and allocates the buffer to handle + /// the given number of voices. + pub fn new(voice_count: usize, sample_rate: f32) -> Self { + Self { + voice_buffer: vec![ + Voice { + oscillator: SquareOscillator::new(sample_rate), + channel: 0, + key_number: 0, + note_id: None, + volume: None, + volume_mod: None, + }; + voice_count + ] + .into_boxed_slice(), + active_voice_count: 0, + } + } + + /// Starts a new voice, playing the given MIDI note key. + /// + /// If there are no more voices available, this does nothing. + fn start_new_voice(&mut self, channel: u8, new_note_key: u8, note_id: Option) { + // Skip the event if we are out of voices + let Some(available_voice) = self.voice_buffer.get_mut(self.active_voice_count) else { + return; + }; + + available_voice.oscillator.reset(); + available_voice.oscillator.set_note_number(new_note_key); + available_voice.channel = channel; + available_voice.key_number = new_note_key; + available_voice.note_id = note_id; + + self.active_voice_count += 1; + } + + /// Stops all voices that match the given MIDI note key and note ID matcher. + /// + /// If no matching voice is found, this does nothing. + fn stop_voices(&mut self, channel: Match, note_key: Match, note_id: Match) { + while let Some(voice_index) = self + .active_voice_buffer() + .iter() + .position(|v| v.matches(channel, note_key, note_id)) + { + // Swap the targeted voice with the last one. + self.voice_buffer.swap(voice_index, self.active_voice_count - 1); + + // Remove the last voice from the active pool. + self.active_voice_count -= 1; + } + } + + /// Stops all active voices. + pub fn stop_all(&mut self) { + self.active_voice_count = 0; + } + + /// Handles the given Note On input event. + pub fn handle_note_on(&mut self, event: &NoteOnEvent) { + if !event.port_index().matches(0u16) { + return; + } + + if let (Match::Specific(channel), Match::Specific(key)) = (event.channel(), event.key()) { + self.start_new_voice(channel as u8, key as u8, event.note_id().into_specific()) + } + } + + /// Handles the given Note Off input event. + pub fn handle_note_off(&mut self, event: &NoteOffEvent) { + if !event.port_index().matches(0u16) { + return; + } + + self.stop_voices(event.channel(), event.key(), event.note_id()) + } + + /// Handles the given polyphonic Parameter Value event. + pub fn handle_param_value(&mut self, event: &ParamValueEvent) { + if !event.port_index().matches(0u16) { + return; + } + + if event.param_id() != PARAM_VOLUME_ID { + return; + } + + for voice in self + .active_voice_buffer_mut() + .iter_mut() + .filter(|v| v.matches(event.channel(), event.key(), event.note_id())) + { + voice.volume = Some(event.value() as f32); + } + } + + /// Handles the given polyphonic Parameter Modulation event. + pub fn handle_param_mod(&mut self, event: &ParamModEvent) { + if !event.port_index().matches(0u16) { + return; + } + + if event.param_id() != PARAM_VOLUME_ID { + return; + } + + for voice in self + .active_voice_buffer_mut() + .iter_mut() + .filter(|v| v.matches(event.channel(), event.key(), event.note_id())) + { + voice.volume_mod = Some(event.amount() as f32); + } + } + + /// Generates the next batch of samples of all the currently active oscillators. + /// Each voice will play at the given volume. + /// + /// This method assumes the buffer is initialized with `0`s. + pub fn generate_next_samples(&mut self, output_buffer: &mut [f32], global_volume: f32, global_volume_mod: f32) { + for voice in self.active_voice_buffer_mut() { + let volume = voice.volume.unwrap_or(global_volume); + let volume_mod = voice.volume_mod.unwrap_or(global_volume_mod); + + voice.oscillator.synth_samples(output_buffer, volume + volume_mod); + } + } + + /// Returns `true` if any voices are currently playing, `false` otherwise. + #[inline] + pub fn has_active_voices(&self) -> bool { + self.active_voice_count > 0 + } + + /// Returns a shared reference to the part of the buffer that only contains the active voices. + #[inline] + fn active_voice_buffer(&self) -> &[Voice] { + &self.voice_buffer[..self.active_voice_count] + } + + /// Returns a mutable reference to the part of the buffer that only contains the active voices. + #[inline] + fn active_voice_buffer_mut(&mut self) -> &mut [Voice] { + &mut self.voice_buffer[..self.active_voice_count] + } +} diff --git a/tests/validate.rs b/tests/validate.rs new file mode 100644 index 0000000..4663131 --- /dev/null +++ b/tests/validate.rs @@ -0,0 +1,134 @@ +#[test] +fn validate_clack_effect() { + validate("clack-effect", TestType::ValidateOk); +} + +#[test] +fn validate_clack_synth() { + validate("clack-synth", TestType::ValidateOk); +} + +#[test] +fn fuzz_clack_effect() { + validate("clack-effect", TestType::Fuzz); +} + +#[test] +fn fuzz_clack_synth() { + validate("clack-synth", TestType::Fuzz); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TestType { + ValidateOk, + ValidateFail, + Fuzz, +} + +/// Runs the validator on the specified package and checks that it behaves as expected. +fn validate(package: &str, test: TestType) { + use std::fs::{copy, create_dir_all, write}; + use std::process::{Command, Stdio}; + + let output = Command::new("cargo") + .args(["build", "--package", package]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .unwrap(); + + assert!(output.status.success(), "Cargo build failed for package '{}'", package); + + let dylib_path = if cfg!(target_os = "windows") { + format!("target/debug/{}.dll", package.replace('-', "_")) + } else if cfg!(target_os = "macos") { + format!("target/debug/lib{}.dylib", package.replace('-', "_")) + } else if cfg!(target_os = "linux") { + format!("target/debug/lib{}.so", package.replace('-', "_")) + } else { + panic!("Unsupported operating system"); + }; + + let plugin_path = if cfg!(target_os = "macos") { + let target_out = format!("target/debug/{}.clap", package); + + create_dir_all(format!("{}/Contents/MacOS", target_out)).unwrap(); + copy(&dylib_path, format!("{}/Contents/MacOS/{}", target_out, package)).unwrap(); + write(format!("{}/Contents/PkgInfo", target_out), "BNDL????").unwrap(); + write( + format!("{}/Contents/Info.plist", target_out), + format!( + r#" + + + + + CFBundleName + {package} + CFBundleExecutable + {package} + CFBundleIdentifier + com.example.{package} + CFBundleVersion + 1.0 + + + "# + ), + ) + .unwrap(); + + target_out + } else { + dylib_path + }; + + match test { + TestType::ValidateOk | TestType::ValidateFail => { + let output = Command::new("cargo") + .args([ + "run", + "--package", + "clap-validator", + "--", + "validate", + "--only-failed", + &plugin_path, + ]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .unwrap(); + + if test == TestType::ValidateFail { + assert!( + !output.status.success(), + "Validation unexpectedly succeeded for '{}'", + package + ); + } else { + assert!(output.status.success(), "Validation failed for '{}'", package); + } + } + + TestType::Fuzz => { + let output = Command::new("cargo") + .args([ + "run", + "--package", + "clap-validator", + "--", + "fuzz", + "-d", + "10s", + &plugin_path, + ]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .unwrap(); + + assert!(output.status.success(), "Fuzzing failed for '{}'", package); + } + } +}