diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 67956bd..37b0381 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench] + plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench, ballast] steps: - uses: https://github.com/actions/checkout@v4 with: @@ -65,6 +65,7 @@ jobs: cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/planish.png --plugin planish cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/braze.png --plugin braze cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/quench.png --plugin quench + cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/ballast.png --plugin ballast - uses: https://github.com/actions/upload-artifact@v3-node20 with: name: editor-previews @@ -99,6 +100,7 @@ jobs: python3 scripts/build-plugin.py --plugin planish --target x86_64-pc-windows-gnu python3 scripts/build-plugin.py --plugin braze --target x86_64-pc-windows-gnu python3 scripts/build-plugin.py --plugin quench --target x86_64-pc-windows-gnu + python3 scripts/build-plugin.py --plugin ballast --target x86_64-pc-windows-gnu python3 scripts/package-windows.py - uses: https://github.com/actions/upload-artifact@v3-node20 with: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e2285ad..8c142f3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,7 +1,7 @@ name: Independent plugin release on: push: - tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*", "quench-v*"] + tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*", "quench-v*", "ballast-v*"] permissions: contents: read diff --git a/Cargo.lock b/Cargo.lock index c229024..069d7e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,25 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "ballast-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", + "serde_json", +] + +[[package]] +name = "ballast-ui" +version = "0.1.0" +dependencies = [ + "ballast-core", + "common-dsp", + "common-ui", + "egui", +] + [[package]] name = "bitflags" version = "2.13.2" diff --git a/Cargo.toml b/Cargo.toml index d23524d..9e666a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/quench/core", "plugins/quench/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"] +members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/quench/core", "plugins/quench/ui", "plugins/ballast/core", "plugins/ballast/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"] exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"] [workspace.package] @@ -33,3 +33,5 @@ opt-level = 2 opt-level = 2 [profile.dev.package.quench-core] opt-level = 2 +[profile.dev.package.ballast-core] +opt-level = 2 diff --git a/README.md b/README.md index 10ec66d..94071b4 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,10 @@ note. Each plugin is a separate product with its own version, guide and release. | **[Planish](docs/planish/README.md)** | Upward and downward compression (OTT) in bands set by harmonic number: band 2 holds harmonics 2–4 on every note | Bass | ≈ 67 ms | [Guide](docs/planish/README.md) · [Changes](docs/planish/CHANGELOG.md) | | **[Braze](docs/braze/README.md)** | Adds a detuned copy whose beating stays on the tempo grid on every note: reese movement in time | Bass | ≈ 67 ms | [Guide](docs/braze/README.md) · [Changes](docs/braze/CHANGELOG.md) | | **[Quench](docs/quench/README.md)** | Clips the top as hard as you like while the sub passes clean: the low end keeps its level under a loud master | Master or bass bus, last | < 1 ms (oversampling) | [Guide](docs/quench/README.md) · [Changes](docs/quench/CHANGELOG.md) | +| **[Ballast](docs/ballast/README.md)** | Levels every note or switch-up to one loudness with a single gain each: no compression inside a part | Bass, after the sound design | ≈ 172 ms | [Guide](docs/ballast/README.md) · [Changes](docs/ballast/CHANGELOG.md) | Plugins with latency report it to the host, so FL Studio compensates the delay. -Anvil, Lathe, Temper, Knurl, Planish and Braze detect pitch from the audio. They can also take the +Anvil, Lathe, Temper, Knurl, Planish, Braze and Ballast detect notes from the audio. They can also take the pitch from MIDI. The order of the plugins matters: see [Signal chain](docs/chain.md). The names come from metalwork: a **rivet** joins pieces; **patina** changes a @@ -43,7 +44,7 @@ turns a piece so each cut lands the same all the way round; **tempering** sets how hard and how tough a metal ends up; a **weld** fuses two pieces into one; **knurling** presses a ridged grip pattern into a surface; **planishing** hammers a surface smooth and even; **brazing** joins two metals with a filler; **quenching** hardens hot metal in one -fast step. +fast step; **ballast** is the weight that keeps a ship level. The suite is the **foundry**: the workshop where the bass gets cast, shaped and finished. Hosts list the plugins under the vendor name *Bass Foundry*. @@ -72,7 +73,7 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*. Quench editor
Quench: sub-safe clipper - + Ballast editor
Ballast: switch-up leveller @@ -132,7 +133,7 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/lathe.p ``` Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`, -`lathe`, `temper`, `weld`, `knurl`, `planish`, `braze` and `quench`. Builds go to `dist//.clap`. No +`lathe`, `temper`, `weld`, `knurl`, `planish`, `braze`, `quench` and `ballast`. Builds go to `dist//.clap`. No script installs anything into a DAW. [Development and testing](docs/development.md) has the full command list. @@ -173,6 +174,7 @@ plugins/ planish/ core · ui · plugin braze/ core · ui · plugin quench/ core · ui · plugin + ballast/ core · ui · plugin common/ ui/ Shared look: palette, embedded fonts, knobs, panels, meters dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files diff --git a/docs/ballast/CHANGELOG.md b/docs/ballast/CHANGELOG.md new file mode 100644 index 0000000..a046787 --- /dev/null +++ b/docs/ballast/CHANGELOG.md @@ -0,0 +1,17 @@ +# Ballast changelog + +## Unreleased (0.1.0) + +- Introduce Ballast, a switch-up leveller: every part of a bassline gets one + gain that brings it to the Target loudness. No compression inside a part. +- Parts start at note starts (detected from the audio, or from MIDI) or on the + host's beat grid, from 1 bar to 1/16. +- Loudness is K-weighted (ITU-R BS.1770) over each part's first 100 ms, in + 10 ms blocks with a relative gate, and each part is weighted from rest. +- The gain glides over 5 ms and is in place on the part's first sample. +- **Learn** takes the next part's loudness as the target. **Amount**, + **Range**, **Measure from** (leave the sub out of the measurement), **Gate**, + **Output**, **Bypass**. +- Editor: the last 32 parts, their loudness in and out, the target, and the + spread of the last 16 parts. +- About 172 ms of latency: the tracker's lookahead, the window and the glide. diff --git a/docs/ballast/README.md b/docs/ballast/README.md new file mode 100644 index 0000000..243e3c9 --- /dev/null +++ b/docs/ballast/README.md @@ -0,0 +1,80 @@ +# Ballast + +Ballast levels every note or switch-up of a bassline to one loudness, with a +single gain for each. Current version **0.1.0** (unreleased). + +Riddim and dubstep basslines change patch every few beats. Patches at the same +peak level still differ in how loud they sound, so each switch-up lands at a +different loudness and gets hand-set gain or clipping. A compressor would even +them out but also change their envelope and punch. Ballast measures each part's +loudness and gives the whole part one gain: inside a part nothing moves. + +![Ballast](assets/editor.png) + +## Install on Windows + +Copy `Ballast.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio +2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find +installed plugins. Load Ballast in the mixer slot of the bass, after its sound +design. Keep the font license with redistributed packages. + +## Work with it + +1. Choose where a part starts with **Parts from**: + - **Notes**: every note start, detected from the audio, or from MIDI notes + sent to the effect (**Notes from**: AUDIO or MIDI). + - **Grid**: every line of the host's beat grid, from 1 bar to 1/16. Use it + when a switch-up changes the patch on the same note: the note tracker does + not see a new note there. +2. Play the bassline and press **LEARN** just before the switch-up that sits + right in the mix. Its loudness becomes the **Target**. You can also set + Target by hand. +3. Watch the display: one bar per part with its loudness as it came in, and a + mark where Ballast puts it. The marks line up on the target line. +4. Lower **Amount** to keep some of the differences: 50 % halves them. + +The panel shows the spread of the last 16 parts, in and out: the standard +deviation of their loudness in dB. In the editor preview four patches spread +by 7 dB come out within 0.4 dB; the rest is what Range left. + +Other controls: + +- **Range** is the most boost or cut a part gets. +- **Measure from**: the measurement hears only what is above this frequency; + the gain still applies to the whole signal. When Anvil makes the sub on the + bus, set it to Anvil's Split: Anvil replaces the sub anyway, so only the part + above it should decide the gain. +- **Gate** is the note-start threshold (Audio). A part quieter than the gate + keeps the last part's gain instead of being lifted, so rests and tails are + not pulled up. +- **Output** trims the level; **Bypass** crossfades to the delayed input. + +### How it measures + +A part's loudness is its K-weighted level (ITU-R BS.1770, the weighting behind +LUFS) over its first 100 ms, or over all of it when it is shorter. The level is +counted in 10 ms blocks, and blocks 20 dB under the part's loudest block are +left out. Each part is weighted from rest, as if it played alone, so the last +part's level does not ring into it. + +The gain glides to the new value over 5 ms and is in place on the part's first +sample, so the attack already has the new level. Later parts of a long note keep +that gain: Ballast does not follow the note's envelope. + +## Timing and limits + +Latency is the note tracker's lookahead (about 67 ms), the 100 ms measuring +window and the 5 ms glide, about 172 ms in all, reported to the host for delay +compensation. That is too much for playing live through Ballast: add it for the +mix. + +In Grid mode the grid follows the host's beat position while it plays and runs +on at the last tempo when it stops. A note start found by a change of pitch can +come about 20 ms early, inside the gap before the note; +the block gate keeps that gap out of the measurement. Two parts closer than +10 ms are merged. + +Ballast decides from the first 100 ms of a part. A patch that swells after +that, or a note whose loudness depends on what plays with it, is levelled by +its start only. It levels loudness, not brightness: two patches at the same +loudness can still differ in tone. diff --git a/docs/ballast/assets/editor.png b/docs/ballast/assets/editor.png new file mode 100644 index 0000000..5f572db Binary files /dev/null and b/docs/ballast/assets/editor.png differ diff --git a/docs/chain.md b/docs/chain.md index 87d61c8..8e46e6e 100644 --- a/docs/chain.md +++ b/docs/chain.md @@ -1,13 +1,13 @@ # Signal chain The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper, -Knurl, Planish and Braze each detect the pitch from the audio they receive. If a plugin earlier +Knurl, Planish, Braze and Ballast each detect the pitch from the audio they receive. If a plugin earlier in the chain changes the audio, the pitch detection after it can go wrong. ## Recommended order ```text -bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Weld +bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Ballast → Weld bass bus: Anvil → (bus compression) → Quench → Crucible master: … → Quench (last, or before a true-peak limiter) ``` @@ -23,7 +23,12 @@ master: … → Quench (last, or before a true-peak limiter) it reads Loose, move Braze to the bus after Anvil, with Low cut at or above Anvil's Split. There it reads the pitch from Anvil's clean sub, and the sub stays out of the beating. -- **Knurl last among the tracked plugins.** See below. +- **Ballast after the sound design.** It levels what the patch finally sounds + like, so it comes after the distortion, OTT and Patina. It finds note starts + like the other tracked plugins: after Knurl, use MIDI or Grid. Weld and Anvil + come after it. On the bus with Anvil, set Measure from to Anvil's Split. +- **Knurl last among the pitch-tracked plugins.** See below. Ballast only + needs note starts; after Knurl, run it from MIDI or the grid. - **Patina after the tracked plugins.** It does not detect pitch, so the order does not change its result. - **Weld after every plugin with latency on its track.** Weld can delay its own @@ -58,8 +63,8 @@ and route the bass's MIDI notes to it. ## Latency Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper, -Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms, Quench -under 1 ms. A full chain is +Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms, Ballast +about 172 ms, Quench under 1 ms. A full chain is about 400 ms. FL Studio compensates this for playback and rendering, but playing a MIDI keyboard through the full chain is not practical. Write with a shorter chain and add the diff --git a/docs/development.md b/docs/development.md index 209f0fb..3648d4b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,8 @@ Patina has `render` for offline WAV evaluation. The shared crates are: - `common-ui`: palette, embedded fonts, header, panels, knobs, meters. - `common-dsp`: filters, YIN pitch tracking, the note tracker used by Anvil, - Lathe, Temper, Knurl, Planish and Braze, 4× oversampling (also Quench's), meters and WAV files. + Lathe, Temper, Knurl, Planish, Braze and Ballast, 4× oversampling (also Quench's), + BS.1770 K-weighting (Ballast), meters and WAV files. The wrappers stay separate workspaces, each with its own lockfile, so the NIH-plug Git dependency stays out of the root workspace. Host identities and @@ -45,7 +46,7 @@ python3 -m unittest discover -s scripts/tests -v ``` Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`, -`knurl`, `planish`, `braze`, `quench`. +`knurl`, `planish`, `braze`, `quench`, `ballast`. `check-plugin.py` runs formatting, tests and clippy for one product's packages and its wrapper, without compiling the other products. Run it for every product @@ -147,6 +148,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects. | Planish | (new) | `io.narl.bass-foundry.planish` | | Braze | (new) | `io.narl.bass-foundry.braze` | | Quench | (new) | `io.narl.bass-foundry.quench` | +| Ballast | (new) | `io.narl.bass-foundry.ballast` | CLAP IDs use the form `io.narl.bass-foundry.`. Builds tagged before this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different diff --git a/docs/ideas.md b/docs/ideas.md index 9f1e8fe..fa4caf8 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -263,8 +263,8 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla Patina already cut fixed formants on every note, but it also cut a boosted harmonic as hard (5.7 dB), at a position that moved with the note. Open: MIDI as the pitch source. -3. Planish (done, released 0.1.0), then Quench (done, unreleased) and Ballast: - they fill the mix and master gap. +3. Planish (done, released 0.1.0), then Quench and Ballast (both done, + unreleased): they fill the mix and master gap. - In parallel, from the research findings, in this order: Weld delay spread (done, Weld 0.3.0); the fatness lane, as the first Assay piece (prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen @@ -376,6 +376,21 @@ the same loudness without compression changing its character. Earlier working name: Hit Leveler. Pairs with Anvil: Anvil levels the sub, Ballast the whole patch. +Built (unreleased 0.1.0, see [Ballast](ballast/README.md)): one gain per part, +parts from note starts or the host grid (a patch change on the same note is not +a new note to the tracker), K-weighted loudness of the first 100 ms, Learn for +the reference. Found while building it: + +- A note start from a pitch change comes about 20 ms early, in the gap before + the note; a plain mean over the window then reads the part ~0.9 dB quiet. + Fixed with 10 ms blocks and a relative gate (−20 dB under the loudest block). +- The K-weighting filters ring the last part into the next one: a near-silent + part after a loud one read −18 LUFS instead of under −60. Fixed by weighting + every part from rest. +- Next ideas: a brightness match next to loudness (problem 2 names both; with + Lathe or Patina doing the EQ), a "follow" option for long notes that swell, + and switch-up detection from timbre change instead of the grid. + ### Bellows — pitch-tracked resonator A comb or Karplus-style resonator tuned to the detected note. It turns noise, diff --git a/plugins/ballast/core/Cargo.toml b/plugins/ballast/core/Cargo.toml new file mode 100644 index 0000000..9109f48 --- /dev/null +++ b/plugins/ballast/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ballast-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +common-dsp = { path = "../../common/dsp" } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/plugins/ballast/core/src/lib.rs b/plugins/ballast/core/src/lib.rs new file mode 100644 index 0000000..3066863 --- /dev/null +++ b/plugins/ballast/core/src/lib.rs @@ -0,0 +1,474 @@ +//! Levels each part of a bassline, a note or a step of the beat grid, to one loudness with a +//! single gain per part. No compression: the gain does not move inside a part, so a patch +//! keeps its own envelope and punch. +//! +//! A part's loudness is the K-weighted (ITU-R BS.1770) mean square of its first 100 ms, or +//! of all of it when it is shorter, counted in 10 ms blocks; blocks 20 dB under the loudest +//! one are left out, so a gap at the start does not pull the reading down. Each part is +//! weighted from rest, as if it played alone. The audio path is delayed by the note tracker's lookahead +//! plus that window plus a 5 ms ramp, so every part's gain is known before its attack leaves +//! the plugin, and the ramp to it ends exactly on the attack. +mod settings; +use common_dsp::loudness::{lufs, KWeighting}; +use common_dsp::svf::{Svf, SvfCoef}; +use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::{coefficient, db_to_gain, gain_to_db}; +pub use settings::{ + Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN, +}; + +/// Each part is measured over this much of its start. +pub const WINDOW_MS: f32 = 100.; +/// The gain glides to a new part's value over this long, ending on its first sample. +pub const RAMP_MS: f32 = 5.; +/// A boundary closer than this to the last one is ignored: the parts are merged. +const MIN_PART_MS: f32 = 10.; +/// The window is measured in blocks of this length. +const BLOCK_MS: f32 = 10.; +const BLOCKS: usize = (WINDOW_MS / BLOCK_MS) as usize; +/// Blocks this far under the part's loudest block do not count: a note start found by a +/// pitch change can come early, inside the gap before the note. +const RELATIVE_GATE_DB: f64 = 20.; +/// Parts shown in the editor. +pub const HISTORY: usize = 32; +/// Parts used for the spread readout. +const SPREAD: usize = 16; +const PENDING: usize = 32; +const Q: f32 = std::f32::consts::FRAC_1_SQRT_2; +/// Tempo the grid runs at before the host reports one. +const FALLBACK_BPM: f64 = 120.; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Part { + /// Loudness of the part's measured start, in LUFS. + pub loudness: f32, + /// Gain given to the part, in dB. + pub gain: f32, + /// Under the gate: the part kept the previous gain. + pub gated: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct AnalyzerFrame { + /// Latest parts, oldest first; the first `count` entries are valid. + pub history: [Part; HISTORY], + pub count: usize, + /// Gain applied at the output right now, in dB. + pub gain: f32, + /// Standard deviation of the loudness of the last 16 parts over the gate, before and after + /// Ballast. NaN with fewer than two. + pub spread_in: f32, + pub spread_out: f32, + /// Waiting for the next part to take as the target. + pub learning: bool, + /// The loudness last taken by Learn, and a number that changes each time one is taken. + pub learned: f32, + pub learn_id: u32, + pub tempo: f64, + pub synced: bool, + pub latency: u32, + pub sample_rate: f32, +} +impl Default for AnalyzerFrame { + fn default() -> Self { + Self { + history: [Part::default(); HISTORY], + count: 0, + gain: 0., + spread_in: f32::NAN, + spread_out: f32::NAN, + learning: false, + learned: f32::NAN, + learn_id: 0, + tempo: FALLBACK_BPM, + synced: false, + latency: 0, + sample_rate: 48000., + } + } +} + +/// Gain in dB that brings `loudness` to the target, at `amount` (0–1), within ±`range`. +pub fn part_gain(loudness: f32, target: f32, amount: f32, range: f32) -> f32 { + ((target - loudness) * amount).clamp(-range, range) +} + +#[derive(Clone, Copy, Default)] +struct Pending { + start: u64, + gain: f32, +} + +pub struct Engine { + rate: f32, + channels: usize, + settings: Settings, + tracker: Tracker, + lookahead: usize, + window: usize, + ramp: usize, + min_part: usize, + delay: Vec<[f32; 2]>, + delay_at: usize, + weighting: [KWeighting; 2], + from: [[Svf; 2]; 2], + from_coef: SvfCoef, + /// Samples of the aligned stream: the input delayed by the tracker's lookahead. + clock: u64, + open: bool, + part_start: u64, + block: usize, + blocks: [(f64, usize); BLOCKS], + measured: usize, + last_gain: f32, + queue: [Pending; PENDING], + queue_head: usize, + queue_len: usize, + gain: f32, + ramp_from: f32, + ramp_to: f32, + ramp_left: usize, + history: [Part; HISTORY], + history_at: usize, + history_len: usize, + learn: bool, + learned: f32, + learn_id: u32, + tempo: f64, + /// Beat position of the aligned stream. + beats: f64, + synced: bool, + cell: i64, + output: f32, + bypass: f32, + smooth: f32, + fresh: bool, +} + +impl Engine { + pub fn new(rate: f32, channels: usize) -> Result { + if !(8000.0..=384000.).contains(&rate) { + return Err("unsupported sample rate"); + } + if !(1..=2).contains(&channels) { + return Err("unsupported channel count"); + } + let tracker = Tracker::new(rate, TrackerConfig::BASS); + let lookahead = tracker.latency(); + let samples = |ms: f32| ((rate * ms * 0.001).round() as usize).max(1); + let (window, ramp) = (samples(WINDOW_MS), samples(RAMP_MS)); + let mut e = Self { + rate, + channels, + settings: Settings::default(), + tracker, + lookahead, + window, + ramp, + min_part: samples(MIN_PART_MS), + delay: vec![[0.; 2]; lookahead + window + ramp + 1], + delay_at: 0, + weighting: [KWeighting::new(rate); 2], + from: [[Svf::default(); 2]; 2], + from_coef: SvfCoef::IDENTITY, + clock: 0, + open: false, + part_start: 0, + block: samples(BLOCK_MS), + blocks: [(0., 0); BLOCKS], + measured: 0, + last_gain: 0., + queue: [Pending::default(); PENDING], + queue_head: 0, + queue_len: 0, + gain: 1., + ramp_from: 1., + ramp_to: 1., + ramp_left: 0, + history: [Part::default(); HISTORY], + history_at: 0, + history_len: 0, + learn: false, + learned: f32::NAN, + learn_id: 0, + tempo: FALLBACK_BPM, + beats: 0., + synced: false, + cell: 0, + output: 1., + bypass: 0., + smooth: coefficient(rate, 10.), + fresh: true, + }; + e.set_settings(Settings::default())?; + e.fresh = true; + Ok(e) + } + /// Lookahead of the tracker, the measuring window and the ramp. + pub fn latency_samples(&self) -> u32 { + (self.lookahead + self.window + self.ramp) as u32 + } + pub fn channels(&self) -> usize { + self.channels + } + pub fn settings(&self) -> Settings { + self.settings + } + pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> { + s.validate()?; + self.tracker.set(s.source == Source::Midi, s.gate, false); + if s.from != self.settings.from || self.fresh { + self.from_coef = if s.from > FROM_MIN { + SvfCoef::highpass(self.rate, s.from, Q) + } else { + SvfCoef::IDENTITY + }; + } + self.settings = s; + if std::mem::take(&mut self.fresh) { + self.output = db_to_gain(s.output); + self.bypass = f32::from(s.bypass); + } + Ok(()) + } + /// Host tempo in BPM and, while playing, the beat position at the block's first sample. + pub fn set_transport(&mut self, tempo: Option, position: Option) { + if let Some(t) = tempo.filter(|t| t.is_finite() && *t > 0.) { + self.tempo = t; + } + match position.filter(|p| p.is_finite()) { + Some(p) => { + // The parts are cut on the aligned stream, `lookahead` samples behind the input. + self.beats = p - self.lookahead as f64 / self.rate as f64 * self.tempo / 60.; + self.synced = true; + } + None => self.synced = false, + } + } + pub fn note_on(&mut self, note: u8) { + self.tracker.note_on(note); + } + pub fn note_off(&mut self, note: u8) { + self.tracker.note_off(note); + } + /// Takes the loudness of the next part over the gate as the new target. + pub fn learn(&mut self) { + self.learn = true; + } + pub fn reset(&mut self) { + self.tracker.reset(); + self.delay.fill([0.; 2]); + self.delay_at = 0; + for k in &mut self.weighting { + k.reset(); + } + self.from = [[Svf::default(); 2]; 2]; + self.clock = 0; + self.open = false; + self.blocks = [(0., 0); BLOCKS]; + self.measured = 0; + self.last_gain = 0.; + self.queue_len = 0; + self.gain = 1.; + self.ramp_left = 0; + self.history_len = 0; + self.learn = false; + self.fresh = true; + let s = self.settings; + let _ = self.set_settings(s); + } + + /// Mean square of the measured blocks, without those far under the loudest. + fn gated_mean(&self) -> f64 { + let used = self.measured.div_ceil(self.block).min(BLOCKS); + let blocks = &self.blocks[..used]; + let loudest = blocks + .iter() + .map(|(s, n)| s / (*n).max(1) as f64) + .fold(0., f64::max); + let floor = loudest * 10f64.powf(-RELATIVE_GATE_DB / 10.); + let (sum, n) = blocks + .iter() + .filter(|(s, n)| s / (*n).max(1) as f64 >= floor) + .fold((0., 0), |(a, b), (s, n)| (a + s, b + n)); + sum / n.max(1) as f64 + } + fn close(&mut self) { + self.open = false; + let s = self.settings; + let loudness = lufs(self.gated_mean()); + let gated = loudness < s.gate; + let gain = if gated { + self.last_gain + } else { + part_gain(loudness, s.target, s.amount * 0.01, s.range) + }; + if !gated { + self.last_gain = gain; + if std::mem::take(&mut self.learn) { + self.learned = loudness; + self.learn_id = self.learn_id.wrapping_add(1); + } + } + if self.queue_len < PENDING { + let at = (self.queue_head + self.queue_len) % PENDING; + self.queue[at] = Pending { + start: self.part_start, + gain, + }; + self.queue_len += 1; + } + self.history[self.history_at] = Part { + loudness, + gain, + gated, + }; + self.history_at = (self.history_at + 1) % HISTORY; + self.history_len = (self.history_len + 1).min(HISTORY); + } + + // Channels index the delayed frame, the filters and the output at once. + #[allow(clippy::needless_range_loop)] + pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] { + let mono = if self.channels == 2 { + 0.5 * (x[0] + x[1]) + } else { + x[0] + }; + let mut started = false; + self.tracker.process(mono, |e| { + if matches!(e, NoteEvent::Start { .. }) { + started = true; + } + }); + let len = self.delay.len(); + self.delay[self.delay_at] = x; + let aligned = self.delay[(self.delay_at + len - self.lookahead) % len]; + self.delay_at = (self.delay_at + 1) % len; + let delayed = self.delay[self.delay_at]; + + // Grid lines are counted on the aligned stream even in Notes mode, so switching modes + // does not cut a part. + let cell = (self.beats / self.settings.grid.quarters()).floor() as i64; + self.beats += self.tempo / 60. / self.rate as f64; + let crossed = cell != self.cell; + self.cell = cell; + let boundary = match self.settings.segment { + Segment::Notes => started, + Segment::Grid => crossed, + }; + + if boundary && !(self.open && self.measured < self.min_part) { + if self.open { + self.close(); + } + self.open = true; + self.part_start = self.clock; + self.blocks = [(0., 0); BLOCKS]; + self.measured = 0; + // Each part is weighted from rest, as if it played alone: the filters' memory of + // the last part would otherwise ring into this one. + for k in &mut self.weighting { + k.reset(); + } + self.from = [[Svf::default(); 2]; 2]; + } + let mut power = 0f64; + for c in 0..self.channels { + let f = &mut self.from[c]; + let v = f[0].process(&self.from_coef, aligned[c]); + let v = f[1].process(&self.from_coef, v); + let w = self.weighting[c].process(v) as f64; + power += w * w; + } + if self.open { + let b = &mut self.blocks[(self.measured / self.block).min(BLOCKS - 1)]; + b.0 += power; + b.1 += 1; + self.measured += 1; + if self.measured >= self.window { + self.close(); + } + } + // A part's ramp starts `ramp` samples before its first sample reaches the output. + if self.queue_len > 0 { + let next = self.queue[self.queue_head]; + if self.clock >= next.start + self.window as u64 { + self.queue_head = (self.queue_head + 1) % PENDING; + self.queue_len -= 1; + self.ramp_from = self.gain; + self.ramp_to = db_to_gain(next.gain); + self.ramp_left = self.ramp; + } + } + if self.ramp_left > 0 { + self.ramp_left -= 1; + let t = 1. - self.ramp_left as f32 / self.ramp as f32; + let t = t * t * (3. - 2. * t); + self.gain = self.ramp_from + (self.ramp_to - self.ramp_from) * t; + } + self.clock += 1; + + let s = self.settings; + self.output += (db_to_gain(s.output) - self.output) * self.smooth; + self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth; + if (f32::from(s.bypass) - self.bypass).abs() < 1e-6 { + self.bypass = f32::from(s.bypass); + } + let mut out = [0.; 2]; + for c in 0..self.channels { + let wet = delayed[c] * self.gain * self.output; + out[c] = if self.bypass >= 1. { + delayed[c] + } else { + wet + (delayed[c] - wet) * self.bypass + }; + } + out + } + + fn spread(&self, after: bool) -> f32 { + let (mut n, mut sum, mut sq) = (0usize, 0f64, 0f64); + for i in 0..self.history_len { + if n == SPREAD { + break; + } + let p = self.history[(self.history_at + HISTORY - 1 - i) % HISTORY]; + if p.gated { + continue; + } + let v = (p.loudness + if after { p.gain } else { 0. }) as f64; + n += 1; + sum += v; + sq += v * v; + } + if n < 2 { + return f32::NAN; + } + let mean = sum / n as f64; + ((sq / n as f64 - mean * mean).max(0.)).sqrt() as f32 + } + + /// Readings for the editor. Never allocates. + pub fn take_analyzer(&self) -> AnalyzerFrame { + let mut f = AnalyzerFrame { + count: self.history_len, + gain: gain_to_db(self.gain), + spread_in: self.spread(false), + spread_out: self.spread(true), + learning: self.learn, + learned: self.learned, + learn_id: self.learn_id, + tempo: self.tempo, + synced: self.synced, + latency: self.latency_samples(), + sample_rate: self.rate, + ..AnalyzerFrame::default() + }; + let first = (self.history_at + HISTORY - self.history_len) % HISTORY; + for i in 0..self.history_len { + f.history[i] = self.history[(first + i) % HISTORY]; + } + f + } +} diff --git a/plugins/ballast/core/src/settings.rs b/plugins/ballast/core/src/settings.rs new file mode 100644 index 0000000..89c74d2 --- /dev/null +++ b/plugins/ballast/core/src/settings.rs @@ -0,0 +1,133 @@ +//! Host-independent controls. Loudness in LUFS, gains in dB, amounts in percent. +use serde::{Deserialize, Serialize}; + +pub const TARGET_MIN: f32 = -40.; +pub const TARGET_MAX: f32 = 0.; +pub const RANGE_MAX: f32 = 24.; +/// Lowest frequency the measurement can start from. At or under this the measurement hears +/// the full band. +pub const FROM_MIN: f32 = 20.; +pub const FROM_MAX: f32 = 300.; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum Source { + /// Note starts are detected from the incoming bass. + #[default] + Audio, + /// Note starts come from MIDI note input. + Midi, +} + +/// Where one leveled part ends and the next begins. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum Segment { + /// Every note start. + #[default] + Notes, + /// Every line of the host's beat grid, so a patch change on the same note counts too. + Grid, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum Grid { + Bar, + Half, + #[default] + Quarter, + Eighth, + Sixteenth, +} +impl Grid { + pub const ALL: [Grid; 5] = [ + Grid::Bar, + Grid::Half, + Grid::Quarter, + Grid::Eighth, + Grid::Sixteenth, + ]; + /// Length in quarter notes. + pub fn quarters(self) -> f64 { + match self { + Grid::Bar => 4., + Grid::Half => 2., + Grid::Quarter => 1., + Grid::Eighth => 0.5, + Grid::Sixteenth => 0.25, + } + } + pub fn label(self) -> &'static str { + match self { + Grid::Bar => "1 BAR", + Grid::Half => "1/2", + Grid::Quarter => "1/4", + Grid::Eighth => "1/8", + Grid::Sixteenth => "1/16", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Settings { + pub source: Source, + pub segment: Segment, + pub grid: Grid, + /// Loudness every part should land at, measured over its first 100 ms. + pub target: f32, + /// Share of the correction that is applied. + pub amount: f32, + /// Most boost or cut, in dB. + pub range: f32, + /// The measurement hears only what is above this frequency; the gain still applies to + /// the full band. Set it to Anvil's Split when Anvil makes the sub. + pub from: f32, + /// Note-start threshold (Audio source), and the loudness under which a part keeps the + /// last gain instead of being lifted. + pub gate: f32, + pub output: f32, + pub bypass: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + source: Source::Audio, + segment: Segment::Notes, + grid: Grid::Quarter, + target: -14., + amount: 100., + range: 12., + from: FROM_MIN, + gate: -48., + output: 0., + bypass: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<(), &'static str> { + let finite = [ + self.target, + self.amount, + self.range, + self.from, + self.gate, + self.output, + ] + .iter() + .all(|v| v.is_finite()); + if !finite { + return Err("non-finite setting"); + } + let ok = (TARGET_MIN..=TARGET_MAX).contains(&self.target) + && (0.0..=100.).contains(&self.amount) + && (0.0..=RANGE_MAX).contains(&self.range) + && (FROM_MIN..=FROM_MAX).contains(&self.from) + && (-60.0..=-20.).contains(&self.gate) + && (-12.0..=12.).contains(&self.output); + if ok { + Ok(()) + } else { + Err("setting out of range") + } + } +} diff --git a/plugins/ballast/core/tests/dsp.rs b/plugins/ballast/core/tests/dsp.rs new file mode 100644 index 0000000..19a59bb --- /dev/null +++ b/plugins/ballast/core/tests/dsp.rs @@ -0,0 +1,332 @@ +use ballast_core::{part_gain, Engine, Grid, Segment, Settings, Source, RAMP_MS, WINDOW_MS}; +use common_dsp::loudness::{lufs, KWeighting}; +use std::f64::consts::TAU; + +const RATE: f32 = 48000.; +const BPM: f64 = 120.; +/// One quarter at 120 BPM. +const QUARTER: usize = 24000; + +/// Two patches a switch-up might alternate: a loud saw and a quiet sine, an octave apart. +fn patch(i: usize, which: usize) -> f32 { + let t = i as f64 / RATE as f64; + if which.is_multiple_of(2) { + let phase = (55. * t).fract(); + (0.5 * (2. * phase - 1.)) as f32 + } else { + (0.1 * (TAU * 110. * t).sin()) as f32 + } +} +fn grid_engine(s: Settings) -> Engine { + let mut e = Engine::new(RATE, 2).unwrap(); + e.set_settings(Settings { + segment: Segment::Grid, + grid: Grid::Quarter, + ..s + }) + .unwrap(); + e +} +/// Runs `input` through the engine in 512-sample blocks with the transport playing from 0. +fn run(e: &mut Engine, input: &[f32]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + for (i, &v) in input.iter().enumerate() { + if i % 512 == 0 { + e.set_transport(Some(BPM), Some(i as f64 / RATE as f64 * BPM / 60.)); + } + out.push(e.process_sample([v, v])[0]); + } + out +} +/// K-weighted loudness of both channels (the same signal) over `window` samples from `start`. +fn loudness(x: &[f32], start: usize, window: usize) -> f32 { + let mut k = KWeighting::new(RATE); + // Settle the filter on what came before, as the engine's own measurement does. + for &v in &x[start.saturating_sub(4800)..start] { + k.process(v); + } + let sum: f64 = x[start..start + window] + .iter() + .map(|&v| (k.process(v) as f64).powi(2)) + .sum(); + lufs(2. * sum / window as f64) +} +fn window() -> usize { + (RATE * WINDOW_MS * 0.001) as usize +} + +#[test] +fn every_part_lands_at_the_target() { + let target = -18.; + let mut e = grid_engine(Settings { + target, + ..Settings::default() + }); + let latency = e.latency_samples() as usize; + let parts = 10; + let input: Vec = (0..parts * QUARTER + latency) + .map(|i| patch(i, i / QUARTER)) + .collect(); + let out = run(&mut e, &input); + for p in 2..parts { + let start = p * QUARTER; + let before = loudness(&input, start, window()); + let after = loudness(&out, start + latency, window()); + assert!( + (after - target).abs() < 0.3, + "part {p}: {before:.1} → {after:.1}" + ); + } + let f = e.take_analyzer(); + assert!(f.spread_in > 5., "{}", f.spread_in); + assert!(f.spread_out < 0.1, "{}", f.spread_out); +} + +#[test] +fn the_gain_holds_still_inside_a_part_and_is_in_place_on_its_attack() { + let mut e = grid_engine(Settings { + target: -18., + ..Settings::default() + }); + let latency = e.latency_samples() as usize; + let ramp = (RATE * RAMP_MS * 0.001) as usize; + let input: Vec = (0..6 * QUARTER + latency) + .map(|i| patch(i, i / QUARTER) + 0.2) + .collect(); + let out = run(&mut e, &input); + for p in 2..5 { + let start = p * QUARTER; + assert!(input[start].abs() > 0.05); + let gain = out[start + latency] / input[start]; + for n in start..start + QUARTER - ramp { + if input[n].abs() < 0.05 { + continue; + } + let g = out[n + latency] / input[n]; + assert!( + (g / gain - 1.).abs() < 1e-4, + "part {p}, sample {n}: {g} vs {gain}" + ); + } + } +} + +#[test] +fn note_starts_from_the_audio_cut_the_parts() { + let target = -18.; + let mut e = Engine::new(RATE, 2).unwrap(); + e.set_settings(Settings { + target, + ..Settings::default() + }) + .unwrap(); + let latency = e.latency_samples() as usize; + // 300 ms notes with 100 ms gaps, alternating the two patches. + let note = 19200; + let input: Vec = (0..8 * note + latency) + .map(|i| { + if i % note < 14400 && i < 8 * note { + patch(i, i / note) + } else { + 0. + } + }) + .collect(); + let out = run(&mut e, &input); + let f = e.take_analyzer(); + let parts = f.history[..f.count].iter().filter(|p| !p.gated).count(); + assert_eq!(parts, 8, "{:?}", &f.history[..f.count]); + for n in 2..8 { + // The tracker finds the start a few milliseconds in; measure past that. + let start = n * note + 960; + let after = loudness(&out, start + latency, window()); + assert!((after - target).abs() < 0.5, "note {n}: {after:.1}"); + } +} + +#[test] +fn midi_note_starts_cut_legato_parts() { + let target = -18.; + let mut e = Engine::new(RATE, 2).unwrap(); + e.set_settings(Settings { + target, + source: Source::Midi, + ..Settings::default() + }) + .unwrap(); + let latency = e.latency_samples() as usize; + let note = QUARTER; + let mut out = Vec::new(); + let input: Vec = (0..6 * note + latency) + .map(|i| patch(i, i / note)) + .collect(); + for (i, &v) in input.iter().enumerate() { + if i % note == 0 { + // Legato: the new note starts before the old one ends, on the same pitch. + e.note_on(33); + if i > 0 { + e.note_off(33); + e.note_on(33); + } + } + out.push(e.process_sample([v, v])[0]); + } + for n in 2..6 { + let after = loudness(&out, n * note + latency, window()); + assert!((after - target).abs() < 0.3, "note {n}: {after:.1}"); + } +} + +#[test] +fn quiet_parts_keep_the_last_gain() { + let mut e = grid_engine(Settings { + target: -18., + gate: -40., + ..Settings::default() + }); + // A loud part, then a part far under the gate, then the loud patch again. The parts change + // on zero crossings of a 110 Hz sine, so no step rings into the quiet part. + let input: Vec = (0..4 * QUARTER) + .map(|i| { + let v = 0.5 * (TAU * 110. * i as f64 / RATE as f64).sin() as f32; + if i / QUARTER == 2 { + v * 0.001 + } else { + v + } + }) + .collect(); + run(&mut e, &input); + let f = e.take_analyzer(); + let parts = &f.history[..f.count]; + // The first part is the silence before the input reaches the measurement. + let quiet = 1 + parts[1..] + .iter() + .position(|p| p.gated) + .expect("a gated part"); + assert_eq!(parts[quiet].gain, parts[quiet - 1].gain); +} + +#[test] +fn amount_and_range_scale_the_correction() { + assert_eq!(part_gain(-10., -14., 1., 12.), -4.); + assert_eq!(part_gain(-10., -14., 0.5, 12.), -2.); + assert_eq!(part_gain(-40., -14., 1., 12.), 12.); + assert_eq!(part_gain(0., -14., 1., 6.), -6.); + let mut e = grid_engine(Settings { + target: -18., + amount: 50., + ..Settings::default() + }); + let input: Vec = (0..4 * QUARTER).map(|i| patch(i, i / QUARTER)).collect(); + run(&mut e, &input); + let f = e.take_analyzer(); + for p in &f.history[1..f.count] { + let full = -18. - p.loudness; + assert!((p.gain - 0.5 * full).abs() < 1e-3, "{p:?}"); + } +} + +#[test] +fn learn_takes_the_next_part_as_the_target() { + let mut e = grid_engine(Settings::default()); + let input: Vec = (0..3 * QUARTER).map(|i| patch(i, i / QUARTER)).collect(); + run(&mut e, &input[..QUARTER + 100]); + e.learn(); + assert!(e.take_analyzer().learning); + let before = e.take_analyzer().learn_id; + run(&mut e, &input[QUARTER + 100..]); + let f = e.take_analyzer(); + assert!(!f.learning); + assert_ne!(f.learn_id, before); + let taken = f.history[..f.count] + .iter() + .find(|p| p.loudness == f.learned) + .expect("learned a part's loudness"); + assert!(!taken.gated); +} + +#[test] +fn the_measurement_can_leave_out_the_sub() { + // The same top, once with a loud sub under it. + let top = |i: usize| (0.05 * (TAU * 1000. * i as f64 / RATE as f64).sin()) as f32; + let sub = |i: usize| (0.5 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32; + let gains = |from: f32| { + let mut e = grid_engine(Settings { + target: -24., + range: 24., + from, + ..Settings::default() + }); + let input: Vec = (0..4 * QUARTER) + .map(|i| { + top(i) + + if (i / QUARTER).is_multiple_of(2) { + sub(i) + } else { + 0. + } + }) + .collect(); + run(&mut e, &input); + let f = e.take_analyzer(); + let p = &f.history[..f.count]; + (p[p.len() - 2].gain - p[p.len() - 1].gain).abs() + }; + assert!(gains(20.) > 10.); + assert!(gains(200.) < 1., "{}", gains(200.)); +} + +#[test] +fn bypass_is_the_input_delayed_by_the_latency() { + let mut e = grid_engine(Settings { + bypass: true, + target: -30., + ..Settings::default() + }); + let latency = e.latency_samples() as usize; + let lookahead = latency - window() - (RATE * RAMP_MS * 0.001) as usize; + // The tracker's lookahead: two periods of 30 Hz and a margin, about 67 ms. + assert!((lookahead as f32 / RATE - 0.067).abs() < 0.005); + let input: Vec = (0..2 * QUARTER).map(|i| patch(i, i / QUARTER)).collect(); + let out = run(&mut e, &input); + for i in latency..input.len() { + assert_eq!(out[i], input[i - latency]); + } +} + +#[test] +fn settings_are_validated_and_serialise() { + assert!(Settings::default().validate().is_ok()); + for bad in [ + Settings { + target: 3., + ..Settings::default() + }, + Settings { + range: 30., + ..Settings::default() + }, + Settings { + from: 10., + ..Settings::default() + }, + Settings { + amount: f32::NAN, + ..Settings::default() + }, + ] { + assert!(bad.validate().is_err()); + assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err()); + } + let s = Settings { + segment: Segment::Grid, + grid: Grid::Eighth, + source: Source::Midi, + target: -9., + ..Settings::default() + }; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + assert!(serde_json::from_str::(r#"{"target":0,"oops":1}"#).is_err()); +} diff --git a/plugins/ballast/core/tests/realtime.rs b/plugins/ballast/core/tests/realtime.rs new file mode 100644 index 0000000..6e3bd81 --- /dev/null +++ b/plugins/ballast/core/tests/realtime.rs @@ -0,0 +1,86 @@ +use ballast_core::{Engine, Grid, Segment, Settings, Source}; +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; + +struct CheckedAllocator; +thread_local! { + static CHECK:Cell=const {Cell::new(false)}; + static OPERATIONS:Cell=const {Cell::new(0)}; +} +fn record() { + if CHECK.try_with(Cell::get).unwrap_or(false) { + let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1)); + } +} +unsafe impl GlobalAlloc for CheckedAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + record(); + unsafe { System.dealloc(pointer, layout) } + } + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record(); + unsafe { System.realloc(pointer, layout, size) } + } +} +#[global_allocator] +static ALLOCATOR: CheckedAllocator = CheckedAllocator; + +#[test] +fn processing_notes_settings_transport_and_analysis_do_not_allocate_or_free() { + let mut engine = Engine::new(48000., 2).unwrap(); + let mut settings = Settings::default(); + engine.set_settings(settings).unwrap(); + CHECK.with(|v| v.set(true)); + for i in 0..96000 { + if i % 512 == 0 { + let playing = (i / 24000) % 2 == 0; + engine.set_transport( + Some(140.), + playing.then_some(i as f64 / 48000. * 140. / 60.), + ); + } + if i % 4800 == 0 { + let step = i / 4800; + settings.segment = if step % 2 == 0 { + Segment::Grid + } else { + Segment::Notes + }; + settings.grid = Grid::ALL[step % Grid::ALL.len()]; + settings.source = if step % 4 < 2 { + Source::Audio + } else { + Source::Midi + }; + settings.from = 20. + 30. * (step % 5) as f32; + settings.bypass = step % 7 == 6; + engine.set_settings(settings).unwrap(); + engine.note_on(33 + (step % 12) as u8); + if step % 3 == 0 { + engine.learn(); + } + } + if i % 4800 == 2400 { + engine.note_off(33 + (i / 4800 % 12) as u8); + } + let t = i as f32 / 48000.; + let x = if (i / 2400) % 2 == 0 { + (std::f32::consts::TAU * 55. * t).sin() * 0.5 + } else { + 0. + }; + std::hint::black_box(engine.process_sample([x, -x])); + if i % 1600 == 0 { + std::hint::black_box(engine.take_analyzer()); + } + } + engine.reset(); + CHECK.with(|v| v.set(false)); + assert_eq!(OPERATIONS.with(Cell::get), 0); +} diff --git a/plugins/ballast/plugin/Cargo.lock b/plugins/ballast/plugin/Cargo.lock new file mode 100644 index 0000000..1100ec2 --- /dev/null +++ b/plugins/ballast/plugin/Cargo.lock @@ -0,0 +1,2381 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d" + +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "ballast" +version = "0.1.0" +dependencies = [ + "ballast-core", + "ballast-ui", + "clap-sys", + "crossbeam-queue", + "nih_plug", + "nih_plug_egui", + "serde_json", +] + +[[package]] +name = "ballast-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "ballast-ui" +version = "0.1.0" +dependencies = [ + "ballast-core", + "common-dsp", + "common-ui", + "egui", +] + +[[package]] +name = "baseview" +version = "0.1.0" +source = "git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0#9a0b42c09d712777b2edb4c5e0cb6baf21e988f0" +dependencies = [ + "cocoa", + "core-foundation", + "keyboard-types", + "nix", + "objc", + "raw-window-handle 0.5.2", + "uuid", + "winapi", + "x11", + "x11rb", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap-sys" +version = "0.5.0" +source = "git+https://github.com/micahrj/clap-sys.git?rev=25d7f53fdb6363ad63fbd80049cb7a42a97ac156#25d7f53fdb6363ad63fbd80049cb7a42a97ac156" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common-dsp" +version = "0.1.0" + +[[package]] +name = "common-ui" +version = "0.1.0" +dependencies = [ + "egui", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "nohash-hasher", + "profiling", +] + +[[package]] +name = "egui-baseview" +version = "0.5.0" +source = "git+https://github.com/BillyDM/egui-baseview.git?rev=ec70c3fe6b2f070dcacbc22924431edbe24bd1c0#ec70c3fe6b2f070dcacbc22924431edbe24bd1c0" +dependencies = [ + "baseview", + "copypasta", + "egui", + "egui_glow", + "keyboard-types", + "log", + "open", + "raw-window-handle 0.5.2", + "thiserror 2.0.21", +] + +[[package]] +name = "egui_glow" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7668b7cff6a51fe61cdde64cd27c8a220786f399501b57ebe36f7d8112fd68" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "midi-consts" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nih_log" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0cdb52ef79af48ae110401c883bdb9c15e0306a99ab6ecf18bc52068b668e54" +dependencies = [ + "atty", + "log", + "once_cell", + "termcolor", + "time", + "windows", +] + +[[package]] +name = "nih_plug" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "anyhow", + "anymap3", + "atomic_float", + "atomic_refcell", + "backtrace", + "bitflags 1.3.2", + "cfg-if", + "clap-sys", + "core-foundation", + "crossbeam", + "libc", + "log", + "midi-consts", + "nih_log", + "nih_plug_derive", + "objc", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", + "serde_json", + "widestring", + "windows", +] + +[[package]] +name = "nih_plug_derive" +version = "0.1.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nih_plug_egui" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "baseview", + "crossbeam", + "egui-baseview", + "nih_plug", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[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", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/ballast/plugin/Cargo.toml b/plugins/ballast/plugin/Cargo.toml new file mode 100644 index 0000000..fd108a5 --- /dev/null +++ b/plugins/ballast/plugin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ballast" +version = "0.1.0" +edition = "2021" +license = "ISC" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } +nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } +ballast-core = { path = "../core" } +ballast-ui = { path = "../ui" } +crossbeam-queue = "0.3" + +[dev-dependencies] +clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" } +serde_json = "1" + +[profile.release] +lto = "thin" +strip = "symbols" diff --git a/plugins/ballast/plugin/src/lib.rs b/plugins/ballast/plugin/src/lib.rs new file mode 100644 index 0000000..1a5018f --- /dev/null +++ b/plugins/ballast/plugin/src/lib.rs @@ -0,0 +1,210 @@ +//! CLAP audio effect. All DSP state is owned by the processing thread. +mod params; +use ballast_core::{AnalyzerFrame, Engine}; +use crossbeam_queue::ArrayQueue; +use nih_plug::prelude::*; +use nih_plug_egui::{create_egui_editor, egui}; +use params::{BallastParams, FLOATS}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +struct Shared { + analyzer: ArrayQueue, + /// Set by the editor's Learn button, taken by the audio thread. + learn: AtomicBool, +} + +struct EditorData { + ui: ballast_ui::State, + gestures: [bool; FLOATS], +} +pub struct Ballast { + params: Arc, + shared: Arc, + engine: Option, + publish_count: usize, + publish_interval: usize, +} +impl Default for Ballast { + fn default() -> Self { + Self { + params: Arc::new(BallastParams::default()), + shared: Arc::new(Shared { + analyzer: ArrayQueue::new(2), + learn: AtomicBool::new(false), + }), + engine: None, + publish_count: 0, + publish_interval: 1600, + } + } +} +impl Plugin for Ballast { + const NAME: &'static str = "Ballast"; + const VENDOR: &'static str = "Bass Foundry"; + const URL: &'static str = ""; + const EMAIL: &'static str = ""; + const VERSION: &'static str = env!("CARGO_PKG_VERSION"); + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[ + AudioIOLayout { + main_input_channels: NonZeroU32::new(2), + main_output_channels: NonZeroU32::new(2), + ..AudioIOLayout::const_default() + }, + AudioIOLayout { + main_input_channels: NonZeroU32::new(1), + main_output_channels: NonZeroU32::new(1), + ..AudioIOLayout::const_default() + }, + ]; + const MIDI_INPUT: MidiConfig = MidiConfig::Basic; + const SAMPLE_ACCURATE_AUTOMATION: bool = false; + type SysExMessage = (); + type BackgroundTask = (); + fn params(&self) -> Arc { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + let params = self.params.clone(); + let shared = self.shared.clone(); + create_egui_editor( + params.editor.clone(), + EditorData { + ui: ballast_ui::State::default(), + gestures: [false; FLOATS], + }, + |ctx, _| ballast_ui::style(ctx), + move |ctx, setter, data| { + while let Some(frame) = shared.analyzer.pop() { + data.ui.accept_frame(frame); + } + let before = params.settings(); + let mut edited = before; + nih_plug_egui::resizable_window::ResizableWindow::new("ballast-editor") + .min_size(egui::vec2( + ballast_ui::WIDTH as f32 - 80., + ballast_ui::HEIGHT as f32 - 40., + )) + .show(ctx, ¶ms.editor, |ui| { + ballast_ui::show(ui, &mut edited, &mut data.ui) + }); + params.apply( + setter, + &before, + &edited, + data.ui.gesture_active, + &mut data.gestures, + ); + if std::mem::take(&mut data.ui.learn_request) { + shared.learn.store(true, Ordering::Release); + } + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + }, + ) + } + fn initialize( + &mut self, + layout: &AudioIOLayout, + config: &BufferConfig, + context: &mut impl InitContext, + ) -> bool { + let Ok(mut engine) = Engine::new( + config.sample_rate, + layout.main_input_channels.map_or(0, |v| v.get() as usize), + ) else { + return false; + }; + let settings = self.params.settings(); + if engine.set_settings(settings).is_err() { + return false; + } + context.set_latency_samples(engine.latency_samples()); + self.publish_interval = (config.sample_rate / 30.) as usize; + self.engine = Some(engine); + true + } + fn reset(&mut self) { + if let Some(e) = self.engine.as_mut() { + e.reset(); + } + self.publish_count = 0; + } + fn process( + &mut self, + buffer: &mut Buffer, + _: &mut AuxiliaryBuffers, + context: &mut impl ProcessContext, + ) -> ProcessStatus { + let Some(engine) = self.engine.as_mut() else { + return ProcessStatus::Normal; + }; + let settings = self.params.settings(); + let _ = engine.set_settings(settings); + if self.shared.learn.swap(false, Ordering::AcqRel) { + engine.learn(); + } + let transport = context.transport(); + engine.set_transport( + transport.tempo, + if transport.playing { + transport.pos_beats() + } else { + None + }, + ); + let mut next = context.next_event(); + for (index, mut sample) in buffer.iter_samples().enumerate() { + while let Some(event) = next { + if event.timing() as usize > index { + break; + } + match event { + NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => { + engine.note_on(note) + } + NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => { + engine.note_off(note) + } + NoteEvent::Choke { note, .. } => engine.note_off(note), + _ => {} + } + next = context.next_event(); + } + let x = [ + *sample.get_mut(0).unwrap(), + if engine.channels() == 2 { + *sample.get_mut(1).unwrap() + } else { + 0. + }, + ]; + let out = engine.process_sample(x); + for (c, value) in sample.into_iter().enumerate() { + *value = out[c]; + } + self.publish_count += 1; + if self.publish_count >= self.publish_interval { + let _ = self.shared.analyzer.force_push(engine.take_analyzer()); + self.publish_count = 0; + } + } + // The lookahead delay must drain after input stops. + ProcessStatus::KeepAlive + } +} +impl ClapPlugin for Ballast { + const CLAP_ID: &'static str = "io.narl.bass-foundry.ballast"; + const CLAP_DESCRIPTION: Option<&'static str> = + Some("Levels every note or switch-up to one loudness with a single gain each"); + const CLAP_MANUAL_URL: Option<&'static str> = None; + const CLAP_SUPPORT_URL: Option<&'static str> = None; + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::AudioEffect, + ClapFeature::Utility, + ClapFeature::Stereo, + ClapFeature::Mono, + ]; +} +nih_export_clap!(Ballast); diff --git a/plugins/ballast/plugin/src/params.rs b/plugins/ballast/plugin/src/params.rs new file mode 100644 index 0000000..e999979 --- /dev/null +++ b/plugins/ballast/plugin/src/params.rs @@ -0,0 +1,203 @@ +use ballast_core::{ + Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN, +}; +use nih_plug::prelude::*; +use nih_plug_egui::EguiState; +use std::sync::Arc; + +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum SourceParam { + #[id = "audio"] + Audio, + #[id = "midi"] + #[name = "MIDI"] + Midi, +} +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum SegmentParam { + #[id = "notes"] + Notes, + #[id = "grid"] + Grid, +} +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum GridParam { + #[id = "bar"] + #[name = "1 bar"] + Bar, + #[id = "1-2"] + #[name = "1/2"] + Half, + #[id = "1-4"] + #[name = "1/4"] + Quarter, + #[id = "1-8"] + #[name = "1/8"] + Eighth, + #[id = "1-16"] + #[name = "1/16"] + Sixteenth, +} +const GRIDS: [(GridParam, Grid); 5] = [ + (GridParam::Bar, Grid::Bar), + (GridParam::Half, Grid::Half), + (GridParam::Quarter, Grid::Quarter), + (GridParam::Eighth, Grid::Eighth), + (GridParam::Sixteenth, Grid::Sixteenth), +]; +fn grid_param(g: Grid) -> GridParam { + GRIDS + .iter() + .find(|(_, v)| *v == g) + .map_or(GridParam::Quarter, |(p, _)| *p) +} +fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam { + FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit) +} +/// Continuous controls, in the order of the gesture slots. +pub const FLOATS: usize = 6; + +#[derive(Params)] +pub struct BallastParams { + #[persist = "editor"] + pub editor: Arc, + #[id = "source"] + pub source: EnumParam, + #[id = "segment"] + pub segment: EnumParam, + #[id = "grid"] + pub grid: EnumParam, + #[id = "target"] + pub target: FloatParam, + #[id = "amount"] + pub amount: FloatParam, + #[id = "range"] + pub range: FloatParam, + #[id = "from"] + pub from: FloatParam, + #[id = "gate"] + pub gate: FloatParam, + #[id = "output"] + pub output: FloatParam, + #[id = "bypass"] + pub bypass: BoolParam, +} +impl Default for BallastParams { + fn default() -> Self { + let d = Settings::default(); + Self { + editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT), + source: EnumParam::new("Note source", SourceParam::Audio), + segment: EnumParam::new("Parts from", SegmentParam::Notes), + grid: EnumParam::new("Grid", grid_param(d.grid)), + target: linear("Target", d.target, TARGET_MIN, TARGET_MAX, " LUFS"), + amount: linear("Amount", d.amount, 0., 100., " %"), + range: linear("Range", d.range, 0., RANGE_MAX, " dB"), + from: FloatParam::new( + "Measure from", + d.from, + FloatRange::Skewed { + min: FROM_MIN, + max: FROM_MAX, + factor: FloatRange::skew_factor(-1.), + }, + ) + .with_unit(" Hz"), + gate: linear("Gate", d.gate, -60., -20., " dB"), + output: linear("Output", d.output, -12., 12., " dB"), + bypass: BoolParam::new("Bypass", false).make_bypass(), + } + } +} +impl BallastParams { + pub fn settings(&self) -> Settings { + let grid = self.grid.value(); + Settings { + source: match self.source.value() { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + }, + segment: match self.segment.value() { + SegmentParam::Notes => Segment::Notes, + SegmentParam::Grid => Segment::Grid, + }, + grid: GRIDS + .iter() + .find(|(p, _)| *p == grid) + .map_or(Grid::Quarter, |(_, g)| *g), + target: self.target.value(), + amount: self.amount.value(), + range: self.range.value(), + from: self.from.value(), + gate: self.gate.value(), + output: self.output.value(), + bypass: self.bypass.value(), + } + } + fn floats(&self) -> [&FloatParam; FLOATS] { + [ + &self.target, + &self.amount, + &self.range, + &self.from, + &self.gate, + &self.output, + ] + } + fn values(s: &Settings) -> [f32; FLOATS] { + [s.target, s.amount, s.range, s.from, s.gate, s.output] + } + /// Pushes every changed editor value to the host. Continuous controls hold one gesture + /// while the pointer is down; switches are set in a single gesture. + pub fn apply( + &self, + setter: &ParamSetter, + old: &Settings, + new: &Settings, + dragging: bool, + open: &mut [bool; FLOATS], + ) { + let pairs = Self::values(old).into_iter().zip(Self::values(new)); + for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() { + if a != b { + if !open[i] { + open[i] = true; + setter.begin_set_parameter(p); + } + setter.set_parameter(p, b); + } + if !dragging && open[i] { + setter.end_set_parameter(p); + open[i] = false; + } + } + if new.bypass != old.bypass { + setter.begin_set_parameter(&self.bypass); + setter.set_parameter(&self.bypass, new.bypass); + setter.end_set_parameter(&self.bypass); + } + if new.source != old.source { + let v = match new.source { + Source::Audio => SourceParam::Audio, + Source::Midi => SourceParam::Midi, + }; + setter.begin_set_parameter(&self.source); + setter.set_parameter(&self.source, v); + setter.end_set_parameter(&self.source); + } + if new.segment != old.segment { + let v = match new.segment { + Segment::Notes => SegmentParam::Notes, + Segment::Grid => SegmentParam::Grid, + }; + setter.begin_set_parameter(&self.segment); + setter.set_parameter(&self.segment, v); + setter.end_set_parameter(&self.segment); + } + if new.grid != old.grid { + setter.begin_set_parameter(&self.grid); + setter.set_parameter(&self.grid, grid_param(new.grid)); + setter.end_set_parameter(&self.grid); + } + } +} diff --git a/plugins/ballast/plugin/tests/host.rs b/plugins/ballast/plugin/tests/host.rs new file mode 100644 index 0000000..036a98d --- /dev/null +++ b/plugins/ballast/plugin/tests/host.rs @@ -0,0 +1,216 @@ +//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW. +use clap_sys::{ + audio_buffer::*, + events::*, + ext::{audio_ports::*, latency::*, state::*}, + factory::plugin_factory::*, + host::*, + process::*, + stream::*, + version::*, +}; +use std::{ + ffi::{c_char, c_void, CStr}, + io::{Cursor, Read}, + ptr, +}; + +unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void { + ptr::null() +} +unsafe extern "C" fn request(_: *const clap_host) {} +unsafe extern "C" fn count(_: *const clap_input_events) -> u32 { + 0 +} +unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header { + ptr::null() +} +unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool { + true +} +unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 { + // SAFETY: The test owns the Vec for the entire synchronous save call. + let bytes = unsafe { &mut *((*stream).ctx as *mut Vec) }; + bytes.extend_from_slice(unsafe { + std::slice::from_raw_parts(buffer as *const u8, size as usize) + }); + size as i64 +} +unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 { + // SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage. + let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor>) }; + cursor + .read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) }) + .unwrap() as i64 +} + +#[test] +fn effect_ports_state_latency_and_audio_lifecycle() { + // SAFETY: All callbacks, strings, streams, buffers, and the host outlive their + // synchronous ABI calls. Activation/processing/destruction follow CLAP order. + unsafe { + let entry = &ballast::clap_entry; + assert!(entry.init.unwrap()(c"ballast.clap".as_ptr())); + let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr()) + as *const clap_plugin_factory; + assert!(!factory.is_null()); + let factory = &*factory; + assert_eq!(factory.get_plugin_count.unwrap()(factory), 1); + let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0); + assert!(!descriptor.is_null()); + assert_eq!(CStr::from_ptr((*descriptor).name), c"Ballast"); + assert_eq!( + CStr::from_ptr((*descriptor).id), + c"io.narl.bass-foundry.ballast" + ); + assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null()); + let host = clap_host { + clap_version: CLAP_VERSION, + host_data: ptr::null_mut(), + name: c"Storyboard test host".as_ptr(), + vendor: c"Tests".as_ptr(), + url: c"".as_ptr(), + version: c"1.0".as_ptr(), + get_extension: Some(extension), + request_restart: Some(request), + request_process: Some(request), + request_callback: Some(request), + }; + let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id); + assert!(!plugin.is_null()); + let p = &*plugin; + assert!(p.init.unwrap()(plugin)); + let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr()) + as *const clap_plugin_audio_ports; + assert!(!ports.is_null()); + assert_eq!((*ports).count.unwrap()(plugin, false), 1); + assert_eq!((*ports).count.unwrap()(plugin, true), 1); + let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr()) + as *const clap_plugin_latency; + assert!(!latency.is_null()); + let state = + p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state; + assert!(!state.is_null()); + let state = &*state; + let save = || { + let mut bytes = Vec::new(); + let stream = clap_ostream { + ctx: &mut bytes as *mut Vec as *mut c_void, + write: Some(write), + }; + assert!(state.save.unwrap()(plugin, &stream)); + bytes + }; + let mut envelope: nih_plug::prelude::PluginState = + serde_json::from_slice(&save()[8..]).unwrap(); + use nih_plug::wrapper::state::ParamValue; + // Amount 0 passes the input through the lookahead delay unchanged. + envelope.params.insert("amount".into(), ParamValue::F32(0.)); + envelope + .params + .insert("target".into(), ParamValue::F32(-20.)); + envelope.params.insert("from".into(), ParamValue::F32(100.)); + envelope + .params + .insert("segment".into(), ParamValue::String("grid".into())); + envelope + .params + .insert("grid".into(), ParamValue::String("1-8".into())); + envelope + .params + .insert("source".into(), ParamValue::String("midi".into())); + let encoded = serde_json::to_vec(&envelope).unwrap(); + let mut framed = (encoded.len() as u64).to_le_bytes().to_vec(); + framed.extend_from_slice(&encoded); + let mut bytes = Cursor::new(framed); + let stream = clap_istream { + ctx: &mut bytes as *mut Cursor> as *mut c_void, + read: Some(read), + }; + assert!(state.load.unwrap()(plugin, &stream)); + let restored: nih_plug::prelude::PluginState = + serde_json::from_slice(&save()[8..]).unwrap(); + assert_eq!( + serde_json::to_value(&restored.params).unwrap(), + serde_json::to_value(&envelope.params).unwrap() + ); + for rate in [44100., 48000., 96000.] { + assert!(p.activate.unwrap()(plugin, rate, 1, 512)); + let delay = (*latency).get.unwrap()(plugin) as usize; + // The tracker's 67 ms, the 100 ms measuring window and the 5 ms ramp. + assert!((delay as f64 / rate - 0.172).abs() < 0.005, "{delay}"); + assert!(p.start_processing.unwrap()(plugin)); + let total = delay + 9600; + let mut position = 0; + let (mut energy_in, mut energy_out) = (0f64, 0f64); + while position < total { + let frames = [1, 127, 512, 31][position % 4].min(total - position); + // A 1 kHz tone at amount 0. + let tone = |n: usize| { + 0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32 + }; + let mut input_left = [0f32; 512]; + let mut input_right = [0f32; 512]; + for i in 0..frames { + input_left[i] = tone(position + i); + input_right[i] = -tone(position + i); + } + let mut left = [0f32; 512]; + let mut right = [0f32; 512]; + let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()]; + let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()]; + let incoming = clap_audio_buffer { + data32: in_channels.as_mut_ptr(), + data64: ptr::null_mut(), + channel_count: 2, + latency: 0, + constant_mask: 0, + }; + let mut outgoing_audio = clap_audio_buffer { + data32: out_channels.as_mut_ptr(), + data64: ptr::null_mut(), + channel_count: 2, + latency: 0, + constant_mask: 0, + }; + let input = clap_input_events { + ctx: ptr::null_mut(), + size: Some(count), + get: Some(event), + }; + let outgoing = clap_output_events { + ctx: ptr::null_mut(), + try_push: Some(push), + }; + let process = clap_process { + steady_time: position as i64, + frames_count: frames as u32, + transport: ptr::null(), + audio_inputs: &incoming, + audio_outputs: &mut outgoing_audio, + audio_inputs_count: 1, + audio_outputs_count: 1, + in_events: &input, + out_events: &outgoing, + }; + assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR); + for i in 0..frames { + let n = position + i; + if n < delay { + assert!(left[i].abs() < 1e-6, "output before the lookahead"); + } else if n > delay + 4800 { + energy_in += (tone(n - delay) as f64).powi(2); + energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2); + } + } + position += frames; + } + let ratio_db = 10. * (energy_out / (2. * energy_in)).log10(); + assert!(ratio_db.abs() < 0.2, "{ratio_db}"); + p.stop_processing.unwrap()(plugin); + p.deactivate.unwrap()(plugin); + } + p.destroy.unwrap()(plugin); + entry.deinit.unwrap()(); + } +} diff --git a/plugins/ballast/ui/Cargo.toml b/plugins/ballast/ui/Cargo.toml new file mode 100644 index 0000000..3f7c519 --- /dev/null +++ b/plugins/ballast/ui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ballast-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +egui = "0.31.1" +ballast-core = { path = "../core" } +common-ui = { path = "../../common/ui" } +common-dsp = { path = "../../common/dsp" } diff --git a/plugins/ballast/ui/src/lib.rs b/plugins/ballast/ui/src/lib.rs new file mode 100644 index 0000000..1862774 --- /dev/null +++ b/plugins/ballast/ui/src/lib.rs @@ -0,0 +1,376 @@ +//! Ballast editor, shared by the CLAP wrapper and the headless renderer. +use ballast_core::{ + AnalyzerFrame, Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, HISTORY, RANGE_MAX, + TARGET_MAX, TARGET_MIN, +}; +use common_ui::*; +use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; + +pub use common_ui::style; +pub const WIDTH: u32 = 980; +pub const HEIGHT: u32 = 660; + +pub struct State { + pub analyzer: AnalyzerFrame, + /// Set by the Learn button; the wrapper passes it to the engine and clears it. + pub learn_request: bool, + /// The last Learn result already applied. None until the first frame arrives, so an old + /// result is not applied again when the editor opens. + learn_seen: Option, + learned: Option, + /// True while a pointer button is held, so knob drags become one host gesture. + pub gesture_active: bool, + pub help: String, +} +impl Default for State { + fn default() -> Self { + Self { + analyzer: AnalyzerFrame::default(), + learn_request: false, + learn_seen: None, + learned: None, + gesture_active: false, + help: DEFAULT_HELP.into(), + } + } +} +const DEFAULT_HELP: &str = "Put Ballast on the bass after its sound design. Press LEARN on the \ + switch-up that sits right, or set Target."; +impl State { + pub fn accept_frame(&mut self, frame: AnalyzerFrame) { + match self.learn_seen { + Some(id) if id != frame.learn_id && frame.learned.is_finite() => { + self.learned = Some(frame.learned); + } + _ => {} + } + self.learn_seen = Some(frame.learn_id); + self.analyzer = frame; + } +} +fn hint(r: &egui::Response, state: &mut State, text: &str) { + if r.hovered() || r.has_focus() { + state.help = text.into(); + } +} +#[allow(clippy::too_many_arguments)] +fn knob( + ui: &mut egui::Ui, + state: &mut State, + name: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + default: f32, + unit: &str, + help: &str, +) { + ui.vertical_centered(|ui| { + let r = rotary(ui, name, value, range, default, unit, 72., ACCENT); + hint(&r, state, help); + }); +} + +const LOW: f32 = -42.; +const HIGH: f32 = 0.; + +/// The last parts: their loudness in (bars), after Ballast (marks) and the target. +fn parts(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 2., SCREEN); + p.text( + rect.left_top() + Vec2::new(10., 8.), + egui::Align2::LEFT_TOP, + format!("LAST {HISTORY} PARTS · LOUDNESS OF THE FIRST 100 MS"), + FontId::proportional(11.), + MUTED, + ); + p.text( + rect.right_top() + Vec2::new(-12., 8.), + egui::Align2::RIGHT_TOP, + "bar: in · mark: out", + FontId::proportional(11.), + MUTED, + ); + let area = Rect::from_min_max( + rect.left_top() + Vec2::new(44., 30.), + rect.right_bottom() - Vec2::new(12., 12.), + ); + let y = |lufs: f32| { + let t = ((lufs - LOW) / (HIGH - LOW)).clamp(0., 1.); + area.bottom() - t * area.height() + }; + for db in [-36., -24., -12., 0.] { + p.line_segment( + [ + Pos2::new(area.left(), y(db)), + Pos2::new(area.right(), y(db)), + ], + Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)), + ); + p.text( + Pos2::new(area.left() - 6., y(db)), + egui::Align2::RIGHT_CENTER, + format!("{db:.0}"), + FontId::monospace(10.), + DIM, + ); + } + let column = area.width() / HISTORY as f32; + // Newest part on the right. + let offset = HISTORY - frame.count; + for i in 0..frame.count { + let part = frame.history[i]; + let x = area.left() + column * (offset + i) as f32; + let bar = Rect::from_min_max( + Pos2::new(x + column * 0.2, y(part.loudness)), + Pos2::new(x + column * 0.8, area.bottom()), + ); + let color = if part.gated { + DIM.gamma_multiply(0.6) + } else { + STEEL.gamma_multiply(0.45) + }; + p.rect_filled(bar, 1., color); + if !part.gated { + let out = y(part.loudness + part.gain); + p.line_segment( + [ + Pos2::new(x + column * 0.1, out), + Pos2::new(x + column * 0.9, out), + ], + Stroke::new(3.0_f32, ACCENT), + ); + } + } + let t = y(settings.target); + p.line_segment( + [Pos2::new(area.left(), t), Pos2::new(area.right(), t)], + Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.5)), + ); + p.text( + Pos2::new(area.left() + 4., t - 3.), + egui::Align2::LEFT_BOTTOM, + format!("TARGET {:.1} LUFS", settings.target), + FontId::proportional(10.), + ACCENT, + ); + p.rect_stroke( + rect, + 2., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); +} + +pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { + backdrop(ui); + state.help = DEFAULT_HELP.into(); + state.gesture_active = ui.input(|i| i.pointer.any_down()); + if let Some(l) = state.learned.take() { + settings.target = l.clamp(TARGET_MIN, TARGET_MAX); + } + ui.add_space(2.); + header(ui, "BALLAST", "SWITCH-UP LEVELLER", |ui| { + let r = ui + .selectable_label(settings.bypass, RichText::new("BYPASS").size(13.)) + .on_hover_text("Crossfade to the delayed dry signal."); + if r.clicked() { + settings.bypass = !settings.bypass; + } + ui.separator(); + if settings.segment == Segment::Notes { + for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] { + let r = + ui.selectable_label(settings.source == source, RichText::new(label).size(13.)); + if r.clicked() { + settings.source = source; + } + } + caption(ui, "NOTES FROM"); + ui.separator(); + } + for (segment, label) in [(Segment::Grid, "GRID"), (Segment::Notes, "NOTES")] { + let r = + ui.selectable_label(settings.segment == segment, RichText::new(label).size(13.)); + if r.clicked() { + settings.segment = segment; + } + } + caption(ui, "PARTS FROM"); + }); + ui.add_space(6.); + let frame = state.analyzer; + ui.horizontal_top(|ui| { + panel(ui, |ui| { + ui.set_width(250.); + ui.set_min_height(TOP); + ui.vertical(|ui| readings(ui, settings, state, &frame)); + }); + ui.add_space(4.); + panel(ui, |ui| { + ui.set_width(ui.available_width()); + parts(ui, settings, &frame, TOP); + }); + }); + ui.add_space(6.); + panel(ui, |ui| { + ui.set_width(ui.available_width()); + ui.vertical(|ui| controls(ui, settings, state)); + }); + ui.separator(); + ui.label(RichText::new(&state.help).color(MUTED)); +} +const TOP: f32 = 300.; + +fn readings(ui: &mut egui::Ui, settings: &Settings, state: &mut State, frame: &AnalyzerFrame) { + caption(ui, "SPREAD OF THE LAST PARTS"); + let known = frame.spread_in.is_finite(); + ui.label( + RichText::new(if known { + format!("{:.1} · {:.1} dB", frame.spread_in, frame.spread_out) + } else { + "—".into() + }) + .font(FontId::new(34., egui::FontFamily::Name("medium".into()))) + .color(if known { TEXT } else { DIM }), + ); + ui.label( + RichText::new("in · out, standard deviation") + .size(12.) + .color(MUTED), + ); + ui.add_space(12.); + readout(ui, "GAIN NOW", format!("{:+.1} dB", frame.gain), ACCENT); + readout(ui, "TARGET", format!("{:.1} LUFS", settings.target), TEXT); + let label = if frame.learning || state.learn_request { + "LISTENING" + } else { + "LEARN" + }; + let r = ui.selectable_label( + frame.learning || state.learn_request, + RichText::new(label).size(13.), + ); + if r.clicked() { + state.learn_request = true; + } + hint( + &r, + state, + "Takes the loudness of the next part over the gate as the target. Press it just \ + before the switch-up that sits right in the mix.", + ); + ui.add_space(10.); + ui.label( + RichText::new(format!( + "{:.0} ms lookahead · {}", + frame.latency as f32 / frame.sample_rate.max(1.) * 1000., + match settings.segment { + Segment::Notes => match settings.source { + Source::Audio => "parts at detected notes".to_string(), + Source::Midi => "parts at MIDI notes".to_string(), + }, + Segment::Grid => format!( + "grid at {:.1} BPM, {}", + frame.tempo, + if frame.synced { + "host transport" + } else { + "free running" + } + ), + } + )) + .size(12.) + .color(MUTED), + ); +} + +fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { + let d = Settings::default(); + if settings.segment == Segment::Grid { + ui.horizontal(|ui| { + caption(ui, "ONE PART EVERY"); + ui.add_space(6.); + for grid in Grid::ALL { + let r = ui + .selectable_label(settings.grid == grid, RichText::new(grid.label()).size(13.)); + if r.clicked() { + settings.grid = grid; + } + hint( + &r, + state, + "A new part starts on every line of this grid, also when the note does not \ + change.", + ); + } + }); + ui.add_space(8.); + ui.separator(); + } + ui.columns(6, |c| { + knob( + &mut c[0], + state, + "Target", + &mut settings.target, + TARGET_MIN..=TARGET_MAX, + d.target, + " LUFS", + "Loudness every part lands at, over its first 100 ms.", + ); + knob( + &mut c[1], + state, + "Amount", + &mut settings.amount, + 0. ..=100., + d.amount, + " %", + "Share of the correction. 50 % halves the differences between parts.", + ); + knob( + &mut c[2], + state, + "Range", + &mut settings.range, + 0. ..=RANGE_MAX, + d.range, + " dB", + "Most boost or cut a part gets.", + ); + knob( + &mut c[3], + state, + "Measure from", + &mut settings.from, + FROM_MIN..=FROM_MAX, + d.from, + " Hz", + "The measurement hears only what is above this. Set it to Anvil's Split when \ + Anvil makes the sub; the gain still applies to the whole signal.", + ); + knob( + &mut c[4], + state, + "Gate", + &mut settings.gate, + -60. ..=-20., + d.gate, + " dB", + "Note-start threshold, and the loudness under which a part keeps the last gain.", + ); + knob( + &mut c[5], + state, + "Output", + &mut settings.output, + -12. ..=12., + d.output, + " dB", + "Output trim.", + ); + }); +} diff --git a/plugins/catalog.json b/plugins/catalog.json index 9580817..e509775 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -182,5 +182,22 @@ "quench-core", "quench-ui" ] + }, + "ballast": { + "name": "Ballast", + "description": "Levels every note or switch-up to one loudness with a single gain each", + "manifest": "plugins/ballast/plugin/Cargo.toml", + "library": "ballast", + "documentation": "docs/ballast", + "clap_id": "io.narl.bass-foundry.ballast", + "licenses": { + "plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt" + }, + "packages": [ + "common-ui", + "common-dsp", + "ballast-core", + "ballast-ui" + ] } } diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index a97da10..d5f4398 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -60,6 +60,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "ballast-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "ballast-ui" +version = "0.1.0" +dependencies = [ + "ballast-core", + "common-dsp", + "common-ui", + "egui", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -171,6 +189,8 @@ version = "0.1.0" dependencies = [ "anvil-core", "anvil-ui", + "ballast-core", + "ballast-ui", "braze-core", "braze-ui", "common-dsp", diff --git a/tools/editor-check/Cargo.toml b/tools/editor-check/Cargo.toml index 9986c3f..9b168ac 100644 --- a/tools/editor-check/Cargo.toml +++ b/tools/editor-check/Cargo.toml @@ -31,5 +31,7 @@ braze-core = { path = "../../plugins/braze/core" } braze-ui = { path = "../../plugins/braze/ui" } quench-core = { path = "../../plugins/quench/core" } quench-ui = { path = "../../plugins/quench/ui" } +ballast-core = { path = "../../plugins/ballast/core" } +ballast-ui = { path = "../../plugins/ballast/ui" } weld-core = { path = "../../plugins/weld/core" } weld-ui = { path = "../../plugins/weld/ui" } diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 8fbff15..1e311b1 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -12,13 +12,13 @@ fn main() { .unwrap_or("rivet"); if ![ "rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish", - "braze", "quench", + "braze", "quench", "ballast", ] .contains(&plugin) { eprintln!( "Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, \ - braze or quench" + braze, quench or ballast" ); std::process::exit(2); } @@ -33,7 +33,10 @@ fn main() { let planish = plugin == "planish"; let braze = plugin == "braze"; let quench = plugin == "quench"; - let (width, height) = if quench { + let ballast = plugin == "ballast"; + let (width, height) = if ballast { + (ballast_ui::WIDTH, ballast_ui::HEIGHT) + } else if quench { (quench_ui::WIDTH, quench_ui::HEIGHT) } else if braze { (braze_ui::WIDTH, braze_ui::HEIGHT) @@ -253,6 +256,37 @@ fn main() { } quench_state.accept_frame(engine.take_analyzer()); } + let mut ballast_settings = ballast_core::Settings { + segment: ballast_core::Segment::Grid, + target: -16., + ..Default::default() + }; + let mut ballast_state = ballast_ui::State::default(); + if ballast { + // A switch-up every quarter at 140 BPM, cycling four patches of different loudness. + let mut engine = ballast_core::Engine::new(48000., 2).unwrap(); + engine.set_settings(ballast_settings).unwrap(); + let mut saw = common_dsp::Saw::new(48000., 43.65); + let quarter = 48000. * 60. / 140.; + for i in 0..48000 * 9 { + if i % 512 == 0 { + engine.set_transport(Some(140.), Some(i as f64 / 48000. * 140. / 60.)); + } + let part = (i as f32 / quarter) as usize; + let s = saw.sample(); + let x = match part % 4 { + 0 => (s * 4.).tanh() * 0.6, + 1 => s * 0.15, + 2 => (s * 2.).tanh() * 0.35, + _ => (s * 8.).tanh() * 0.9, + }; + engine.process_sample([x, x]); + if i % 1600 == 0 { + ballast_state.accept_frame(engine.take_analyzer()); + } + } + ballast_state.accept_frame(engine.take_analyzer()); + } let mut weld_settings = weld_core::Settings::default(); let mut weld_state = weld_ui::State::default(); if weld { @@ -382,7 +416,9 @@ fn main() { let session_motif = session.motifs[0].id; shared.replace(session).unwrap(); let ctx = egui::Context::default(); - if quench { + if ballast { + ballast_ui::style(&ctx); + } else if quench { quench_ui::style(&ctx); } else if braze { braze_ui::style(&ctx); @@ -425,7 +461,9 @@ fn main() { }, |ui| { egui::CentralPanel::default().show(ui, |ui| { - if quench { + if ballast { + ballast_ui::show(ui, &mut ballast_settings, &mut ballast_state); + } else if quench { quench_ui::show(ui, &mut quench_settings, &mut quench_state); } else if braze { braze_ui::show(ui, &mut braze_settings, &mut braze_state); diff --git a/website/src/data/plugins.ts b/website/src/data/plugins.ts index 01dd806..e168e0c 100644 --- a/website/src/data/plugins.ts +++ b/website/src/data/plugins.ts @@ -214,6 +214,21 @@ const COPY: Record = { latency: '< 1 ms', tracksNotes: false, }, + ballast: { + role: 'Switch-up leveller', + metal: 'Ballast is the weight that keeps a ship level.', + problem: + 'Patches at the same peak level still sound louder or quieter, so every switch-up lands at a different loudness and gets hand-set gain or clipping. A compressor evens them out but changes their punch.', + points: [ + 'One gain per part: nothing moves inside a note or switch-up.', + 'Parts start at note starts, from the audio or MIDI, or on the host’s beat grid.', + 'Loudness is K-weighted (BS.1770) over each part’s first 100 ms; Learn takes a reference part.', + 'Measure from leaves the sub out of the measurement when Anvil makes it.', + ], + loadOn: 'Bass, after the sound design', + latency: '≈ 172 ms', + tracksNotes: true, + }, }; export interface Plugin extends Copy {