diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml
index 0a91601..32e0234 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]
+ plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -62,6 +62,7 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/knurl.png --plugin knurl
+ cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/planish.png --plugin planish
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -93,6 +94,7 @@ jobs:
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin knurl --target x86_64-pc-windows-gnu
+ python3 scripts/build-plugin.py --plugin planish --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 9ea3625..60e5c9a 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*"]
+ tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*"]
permissions:
contents: read
diff --git a/Cargo.lock b/Cargo.lock
index 80a45a7..6b8583d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -327,6 +327,25 @@ dependencies = [
"patina-core",
]
+[[package]]
+name = "planish-core"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "planish-ui"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "common-ui",
+ "egui",
+ "planish-core",
+]
+
[[package]]
name = "primal-check"
version = "0.3.4"
diff --git a/Cargo.toml b/Cargo.toml
index 9f38650..bb4a0fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[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/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"]
+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/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]
edition = "2021"
@@ -26,6 +26,8 @@ opt-level = 2
opt-level = 2
[profile.dev.package.knurl-core]
opt-level = 2
+[profile.dev.package.planish-core]
+opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
diff --git a/README.md b/README.md
index ea666d0..6ba78ee 100644
--- a/README.md
+++ b/README.md
@@ -28,9 +28,10 @@ note. Each plugin is a separate product with its own version, guide and release.
| **[Temper](docs/temper/README.md)** | Distortion that drives every note the same: level and tone filters follow the note | Bass | ≈ 67 ms + oversampling | [Guide](docs/temper/README.md) · [Changes](docs/temper/CHANGELOG.md) |
| **[Weld](docs/weld/README.md)** | Phase-aligns one bass layer to another layer through the sidechain, so the layers sum in the mids instead of cancelling | One layer's track, other layer as sidechain | none | [Guide](docs/weld/README.md) · [Changes](docs/weld/CHANGELOG.md) |
| **[Knurl](docs/knurl/README.md)** | Gives every cycle of the note its own level, timing and grit: in-tune subharmonic growl, rattle and fry on every note | Bass | ≈ 71 ms | [Guide](docs/knurl/README.md) · [Changes](docs/knurl/CHANGELOG.md) |
+| **[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) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
-Anvil, Lathe, Temper and Knurl detect pitch from the audio. They can also take the
+Anvil, Lathe, Temper, Knurl and Planish detect pitch 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
@@ -38,7 +39,8 @@ surface and keeps its character; an **anvil** is the solid base that everything
is struck on; a **crucible** melts material down to be cast again; a **lathe**
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.
+**knurling** presses a ridged grip pattern into a surface; **planishing**
+hammers a surface smooth and even.
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*.
@@ -61,6 +63,10 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
 Weld: layer phase alignment |
 Knurl: cycle texture |
+
+  Planish: harmonic OTT |
+ |
+
The previews are rendered headless from the real editor code by
@@ -119,7 +125,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` and `knurl`. Builds go to `dist//.clap`. No
+`lathe`, `temper`, `weld`, `knurl` and `planish`. Builds go to `dist//.clap`. No
script installs anything into a DAW. [Development and testing](docs/development.md)
has the full command list.
@@ -157,6 +163,7 @@ plugins/
temper/ core · ui · plugin
weld/ core · ui · plugin
knurl/ core · ui · plugin
+ planish/ 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/chain.md b/docs/chain.md
index c241ff3..ddb7743 100644
--- a/docs/chain.md
+++ b/docs/chain.md
@@ -1,18 +1,21 @@
# Signal chain
-The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper
-and Knurl each detect the pitch from the audio they receive. If a plugin earlier
+The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
+Knurl and Planish 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 → Knurl → Patina → Weld
+bass track: synth → Lathe → Temper → Planish → Knurl → Patina → Weld
bass bus: Anvil → (bus compression, clipping) → Crucible
```
- **Lathe and Temper before Knurl.** They hear the plain synth, where the pitch
is clear.
+- **Planish after the distortion, before Knurl.** OTT usually comes after
+ distortion. Planish's band edges follow the tracked note, so it also needs a
+ clear pitch: after Knurl, use MIDI.
- **Knurl last among the tracked plugins.** See below.
- **Patina after the tracked plugins.** It does not detect pitch, so the order
does not change its result.
@@ -35,15 +38,16 @@ tracker on a saw bass:
- Below C2 the false sub-octave is under 30 Hz, out of the detection range, so
low notes are tracked correctly.
- Level and time jitter add 5–25 false note starts per second. Anvil restarts
- its phase on each, Lathe and Temper retune on each.
+ its phase on each, Lathe, Temper and Planish retune on each.
When a tracked plugin must come after Knurl, select **MIDI** as its pitch source
and route the bass's MIDI notes to it.
## Latency
-Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe and Temper about
-67 ms, Knurl about 71 ms, Patina about 45 ms. A full chain is about 330 ms. FL
+Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper and
+Planish about 67 ms, Knurl about 71 ms, Patina about 45 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
rest for the mix.
diff --git a/docs/development.md b/docs/development.md
index c40fa12..1b9f8ad 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -25,7 +25,7 @@ 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 and Knurl, 4× oversampling, meters and WAV files.
+ Lathe, Temper, Knurl and Planish, 4× oversampling, 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 +45,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
-`knurl`.
+`knurl`, `planish`.
`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
@@ -144,6 +144,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Temper | (new) | `io.narl.bass-foundry.temper` |
| Weld | (new) | `io.narl.bass-foundry.weld` |
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
+| Planish | (new) | `io.narl.bass-foundry.planish` |
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 18c43db..cb69f2b 100644
--- a/docs/ideas.md
+++ b/docs/ideas.md
@@ -131,6 +131,14 @@ burst per cycle tuned to the note. Amounts are counted in cycles, so the texture
is the same on every note. Slow per-harmonic drift is postponed. See
[Knurl](knurl/README.md).
+### Planish — harmonic OTT (problem 16)
+
+Upward and downward compression in four bands whose edges are harmonic numbers
+of the tracked note (default 1, 2–4, 5–16, 17 and up), so the same setting
+squashes the same harmonics on every note. Each band's level is measured over
+one period of the note, so heavy settings do not ripple at f0. Built, not yet
+released. See [Planish](planish/README.md).
+
## Next for released plugins
- **Anvil**: *Kick lock*: the kick through the sidechain ducks the sub on each
@@ -167,7 +175,8 @@ is the same on every note. Slow per-harmonic drift is postponed. See
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, then Quench and Ballast: they fill the mix and master gap.
+3. Planish (done, unreleased), then Quench and Ballast: they fill the mix and
+ master gap.
4. Forge, when enough modules exist.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
@@ -185,13 +194,6 @@ before Knurl) and the repeated MIDI routing. The standalone plugins stay.
Sharing one tracker between separate `.clap` files would need named shared
memory across DLLs, which is fragile; one strip is simpler.
-### Planish — OTT on harmonic numbers (problem 16)
-
-Planishing smooths metal with a hammer. Upward and downward compression in
-bands whose edges are harmonic numbers of the tracked note (for example 1,
-2–4, 5–16, 17 and up) instead of fixed Hz. OTT is on almost every riddim patch,
-and with fixed bands it acts differently on every note.
-
### Quench — sub-safe clipper (problem 17)
Quenching hardens hot metal in one fast step. Quench clips only above a split
diff --git a/docs/planish/CHANGELOG.md b/docs/planish/CHANGELOG.md
new file mode 100644
index 0000000..e593475
--- /dev/null
+++ b/docs/planish/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Planish changelog
+
+## 0.1.0
+
+- Introduce Planish, upward and downward compression in four bands whose edges
+ are harmonic numbers of the played note.
+- Band edges set by harmonic (Low 1–8, Mid 2–16, High 3–48), with splits
+ Classic, Sub + body, Tight and Wide top, or dragged on the harmonic ruler.
+- Depth, Drive, Upward, Downward, Time and a gain per band.
+- Band level measured over one period of the note, so heavy compression does
+ not ripple at the fundamental.
+- Detect pitch and note starts from the audio with about 67 ms of latency, or
+ follow MIDI notes sent to the effect.
+- Editor shows the bands on a harmonic ruler and each band's level and gain.
diff --git a/docs/planish/README.md b/docs/planish/README.md
new file mode 100644
index 0000000..a07fb43
--- /dev/null
+++ b/docs/planish/README.md
@@ -0,0 +1,97 @@
+# Planish
+
+Planish is upward and downward compression (OTT) in four bands whose edges are
+harmonic numbers of the played note. Current version **0.1.0**.
+
+Planishing hammers a metal surface smooth and even. OTT does the same to a bass:
+it pushes loud bands down and lifts quiet ones, and it is on almost every riddim
+patch. A normal OTT splits the bands at fixed frequencies, though. A split at
+88 Hz and 2.5 kHz puts harmonic 2 of F1 in the low band but harmonic 2 of C2 in
+the mid band, so the same setting squashes different harmonics on every note.
+Planish moves its band edges with the note: with the default split, band 1 is
+the fundamental, band 2 harmonics 2–4, band 3 harmonics 5–16 and band 4
+everything above, on F1 as on G2.
+
+
+
+## Install on Windows
+
+Copy `Planish.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 Planish in a mixer slot of the bass. Keep the font
+license with redistributed packages.
+
+## Work with it
+
+1. Play the bass. The note readout shows what Planish tracks. The ruler at the
+ top shows harmonics 1–64 and the four bands; the columns below show each
+ band's level (steel) and gain (pink for a cut, blue for a lift).
+2. Choose a split, or drag an edge on the ruler:
+ - **Classic** (1, 2–4, 5–16, 17+, the default): fundamental on its own.
+ - **Sub + body** (1–2, 3–6, 7–24, 25+): the octave stays with the sub.
+ - **Tight** (1, 2, 3–8, 9+): close control of the first harmonics.
+ - **Wide top** (1, 2–3, 4–32, 33+): one big mid band, the top is fizz only.
+3. Set the amount:
+ - **Depth** blends each band with its compressed copy. 0 % is a clean sum
+ of the bands; the default is 50 %.
+ - **Drive** moves the band levels against the thresholds: more drive, more
+ squash. It does not change the level by itself.
+ - **Upward** and **Downward** scale the two directions. Downward pushes a
+ band above −30 dB down at about 20:1; Upward lifts a band below −42 dB at
+ about 4:1, by at most 24 dB.
+ - **Time** scales every attack and release. Lower bands are slower: at
+ 100 %, attack is 30, 15, 8 and 4 ms and release 200, 160, 120 and 90 ms
+ from band 1 to band 4.
+4. **Band 1–4** set each band's gain after its compressor. Like the
+ compression, they are scaled by Depth.
+
+**Output** trims the level. **Bypass** crossfades to the delayed dry signal.
+
+### Pitch from MIDI
+
+For basses whose fundamental is unclear (heavy FM, formant or noise patches),
+and when Planish comes after Knurl, select **MIDI** and route the bass's MIDI
+notes to Planish. In FL Studio, set the bass channel's MIDI output port and
+Planish's input port to the same number. **Gate** sets the input level that
+counts as a note in AUDIO mode. **Snap to semitone** rounds the detected pitch;
+it is off by default, because the level is measured over one period of the
+pitch in use, and a detuned patch fits its real period better.
+
+## Measured
+
+- At Depth 0 the bands sum flat: sines from 35 Hz to 12 kHz come out within
+ 0.05 dB.
+- The same setting changes the same harmonics on every note. At Depth 100 %
+ on a saw bass, harmonic 1 is cut by 13.6 dB on F1, C2 and G2; harmonics 1–12
+ differ by less than 0.2 dB between the three notes.
+- No ripple at the fundamental. A 40 Hz sine at Drive +12 dB and Time 10 % gets
+ no harmonics above −110 dB.
+
+## Timing and limits
+
+Planish delays the audio by about 67 ms of pitch-detection lookahead and
+reports it to the host for delay compensation, so the edges are on the new note
+when its attack leaves the plugin. Pitch range is 30–500 Hz, one note at a time.
+Before the first note the edges sit on 55 Hz; between notes they hold the last
+note.
+
+An edge is a slope, not a wall. The crossovers are 24 dB per octave, so the
+harmonics next to an edge partly reach the next band, by the same amount on
+every note. Both channels get the same gain per band, so the stereo image does
+not move. Quiet signals below about −84 dB are not lifted, so noise floors and
+tails stay down.
+
+Planish detects the pitch like Lathe and Temper. Put it before Knurl, or use
+MIDI: see [Signal chain](../chain.md).
+
+## How it works
+
+A note tracker (shared with Anvil, Lathe, Temper and Knurl) detects pitch and
+note starts ahead of the delayed audio. Three Linkwitz–Riley crossovers split
+the signal at √(k(k+1)) × f0, halfway in log frequency between harmonic k and
+k+1, and allpasses keep the lower bands in phase with the upper ones, so the
+bands sum to an allpass. The crossovers retune every 16 samples and glide with
+**Smooth** on bends. Each band's level is the RMS over exactly one period of
+the note, so a steady note gives a steady level and the gain does not wobble
+at f0. The level goes through attack and release smoothing, then a static
+curve with an upward and a downward threshold sets the gain.
diff --git a/docs/planish/assets/editor.png b/docs/planish/assets/editor.png
new file mode 100644
index 0000000..7783fe1
Binary files /dev/null and b/docs/planish/assets/editor.png differ
diff --git a/docs/releases.md b/docs/releases.md
index 2d00caf..55b1d2d 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -15,6 +15,7 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| Temper | `temper-v0.2.0` | `temper-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.2.0` | `weld-windows-x86_64-0.2.0.zip` |
| Knurl | `knurl-v0.2.0` | `knurl-windows-x86_64-0.2.0.zip` |
+| Planish | `planish-v0.1.0` | `planish-windows-x86_64-0.1.0.zip` |
## Make a release
diff --git a/plugins/catalog.json b/plugins/catalog.json
index 4c53977..604741e 100644
--- a/plugins/catalog.json
+++ b/plugins/catalog.json
@@ -131,5 +131,22 @@
"knurl-core",
"knurl-ui"
]
+ },
+ "planish": {
+ "name": "Planish",
+ "description": "Upward and downward compression in bands set by harmonic number, the same on every note",
+ "manifest": "plugins/planish/plugin/Cargo.toml",
+ "library": "planish",
+ "documentation": "docs/planish",
+ "clap_id": "io.narl.bass-foundry.planish",
+ "licenses": {
+ "plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
+ },
+ "packages": [
+ "common-ui",
+ "common-dsp",
+ "planish-core",
+ "planish-ui"
+ ]
}
}
diff --git a/plugins/planish/core/Cargo.toml b/plugins/planish/core/Cargo.toml
new file mode 100644
index 0000000..c325c9e
--- /dev/null
+++ b/plugins/planish/core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "planish-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/planish/core/src/lib.rs b/plugins/planish/core/src/lib.rs
new file mode 100644
index 0000000..ed0b7dd
--- /dev/null
+++ b/plugins/planish/core/src/lib.rs
@@ -0,0 +1,412 @@
+//! Upward and downward compression in four bands whose edges are harmonic numbers of the
+//! played note, so the same setting squashes the same harmonics on every note.
+//!
+//! The crossovers are Linkwitz–Riley splits retuned to the tracked fundamental, with allpass
+//! compensation, so the bands sum flat. An edge sits halfway (in log frequency) between two
+//! harmonics. It is a slope, not a wall: neighbours of an edge partly reach the next band, by
+//! the same amount on every note. Each band's level is measured over exactly one period
+//! of the note: a steady note gives a steady level, and the gain does not ripple at the
+//! fundamental. The audio path is delayed by the tracker's lookahead, so the edges are on the
+//! new note when its attack leaves the plugin.
+mod settings;
+use common_dsp::svf::{Svf, SvfCoef};
+use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
+use common_dsp::{coefficient, db_to_gain, hz_to_midi};
+pub use settings::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
+
+pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
+pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
+/// Fundamental the edges sit on before the first note.
+pub const FALLBACK_HZ: f32 = 55.;
+/// Band levels are RMS over one period: a sine with peak 1 reads −3 dB.
+pub const DOWN_THRESHOLD: f32 = -30.;
+pub const UP_THRESHOLD: f32 = -42.;
+/// Gain change per dB beyond the threshold at 100 %: about 20:1 downward, 4:1 upward.
+const DOWN_SLOPE: f32 = 0.95;
+const UP_SLOPE: f32 = 0.75;
+/// Most lift a band can get.
+pub const UP_MAX: f32 = 24.;
+/// Below this level the lift fades out, so silence and noise floors are not raised.
+pub const FLOOR: f32 = -84.;
+const FLOOR_FADE: f32 = 12.;
+/// Attack and release at Time 100 %, bottom band first: lower bands move slower.
+pub const ATTACK_MS: [f32; BANDS] = [30., 15., 8., 4.];
+pub const RELEASE_MS: [f32; BANDS] = [200., 160., 120., 90.];
+/// Filter coefficients are recomputed this often, in samples.
+const RETUNE: usize = 16;
+const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
+const SILENT: f32 = -120.;
+
+#[derive(Clone, Copy, Debug)]
+pub struct AnalyzerFrame {
+ /// Fundamental the edges are tuned to, 0 before the first note.
+ pub hz: f32,
+ /// Sounding note, rounded; None between notes.
+ pub note: Option,
+ /// Crossover frequencies in Hz between the bands.
+ pub edges: [f32; BANDS - 1],
+ /// Band level plus Drive, in dB RMS, as the compressors see it.
+ pub levels: [f32; BANDS],
+ /// Gain of each compressor in dB at full depth, before the band gains.
+ pub gains: [f32; BANDS],
+ pub latency: u32,
+ pub sample_rate: f32,
+}
+impl Default for AnalyzerFrame {
+ fn default() -> Self {
+ Self {
+ hz: 0.,
+ note: None,
+ edges: [0.; BANDS - 1],
+ levels: [SILENT; BANDS],
+ gains: [0.; BANDS],
+ latency: 0,
+ sample_rate: 48000.,
+ }
+ }
+}
+
+/// Gain in dB for a band level in dB, at the given upward and downward amounts (0–1).
+pub fn compressor_gain(level: f32, upward: f32, downward: f32) -> f32 {
+ let down = if level > DOWN_THRESHOLD {
+ -(level - DOWN_THRESHOLD) * DOWN_SLOPE * downward
+ } else {
+ 0.
+ };
+ let up = if level < UP_THRESHOLD {
+ let fade = ((level - FLOOR) / FLOOR_FADE).clamp(0., 1.);
+ let fade = fade * fade * (3. - 2. * fade);
+ ((UP_THRESHOLD - level) * UP_SLOPE * upward).min(UP_MAX) * fade
+ } else {
+ 0.
+ };
+ down + up
+}
+
+/// Mean of the last `n` values, with `n` changed only after a full window so the running sum
+/// can be refreshed in the same step. Allocation-free after `new`.
+struct Window {
+ ring: Vec,
+ at: usize,
+ n: usize,
+ next: usize,
+ count: usize,
+ sum: f32,
+}
+impl Window {
+ fn new(capacity: usize) -> Self {
+ Self {
+ ring: vec![0.; capacity + 1],
+ at: 0,
+ n: 1,
+ next: 1,
+ count: 0,
+ sum: 0.,
+ }
+ }
+ fn set_len(&mut self, n: usize) {
+ self.next = n.clamp(1, self.ring.len() - 1);
+ }
+ fn push(&mut self, v: f32) -> f32 {
+ let cap = self.ring.len();
+ self.sum += v - self.ring[(self.at + cap - self.n) % cap];
+ self.ring[self.at] = v;
+ self.at = (self.at + 1) % cap;
+ self.count += 1;
+ if self.count >= self.n {
+ // Exact sum over the new length: no drift, and a length change takes effect here.
+ self.count = 0;
+ self.n = self.next;
+ self.sum = (1..=self.n)
+ .map(|i| self.ring[(self.at + cap - i) % cap])
+ .sum();
+ }
+ self.sum.max(0.) / self.n as f32
+ }
+ fn reset(&mut self) {
+ self.ring.fill(0.);
+ self.sum = 0.;
+ self.count = 0;
+ }
+}
+
+/// Filter state of one channel: three LR4 splits and the allpasses that keep the lower bands
+/// in phase with the upper ones.
+#[derive(Clone, Copy, Default)]
+struct Channel {
+ low: [[Svf; 2]; BANDS - 1],
+ high: [[Svf; 2]; BANDS - 1],
+ /// Band 1 through the allpasses of splits 2 and 3, band 2 through split 3.
+ phase: [Svf; 3],
+}
+impl Channel {
+ // Bands, splits and channels index several parallel arrays at once.
+ #[allow(clippy::needless_range_loop)]
+ fn split(&mut self, c: &Coefs, x: f32) -> [f32; BANDS] {
+ let mut rest = x;
+ let mut bands = [0.; BANDS];
+ for s in 0..BANDS - 1 {
+ let l = self.low[s][0].process(&c.low[s], rest);
+ bands[s] = self.low[s][1].process(&c.low[s], l);
+ let h = self.high[s][0].process(&c.high[s], rest);
+ rest = self.high[s][1].process(&c.high[s], h);
+ }
+ bands[BANDS - 1] = rest;
+ let b0 = self.phase[0].process(&c.all[1], bands[0]);
+ bands[0] = self.phase[1].process(&c.all[2], b0);
+ bands[1] = self.phase[2].process(&c.all[2], bands[1]);
+ bands
+ }
+}
+#[derive(Clone, Copy)]
+struct Coefs {
+ low: [SvfCoef; BANDS - 1],
+ high: [SvfCoef; BANDS - 1],
+ all: [SvfCoef; BANDS - 1],
+}
+
+pub struct Engine {
+ rate: f32,
+ channels: usize,
+ settings: Settings,
+ latency: usize,
+ delay: Vec<[f32; 2]>,
+ delay_at: usize,
+ tracker: Tracker,
+ pitch: PitchFollower,
+ hz: f32,
+ hz_coef: f32,
+ countdown: usize,
+ edges: [f32; BANDS - 1],
+ coefs: Coefs,
+ state: [Channel; 2],
+ windows: [Window; BANDS],
+ levels: [f32; BANDS],
+ gains: [f32; BANDS],
+ attack: [f32; BANDS],
+ release: [f32; BANDS],
+ smooth: f32,
+ depth: f32,
+ drive: f32,
+ band_gains: [f32; BANDS],
+ output: f32,
+ bypass: f32,
+ /// Smoothed controls start at the next settings instead of gliding from defaults.
+ fresh: bool,
+}
+
+/// One-pole step that lands exactly on the target, so switched states end bit-exact.
+fn approach(value: &mut f32, target: f32, coef: f32) {
+ *value += (target - *value) * coef;
+ if (target - *value).abs() < 1e-6 {
+ *value = target;
+ }
+}
+
+impl Engine {
+ pub fn new(rate: f32, channels: usize) -> Result {
+ if !(8000.0..=768000.).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 latency = tracker.latency();
+ let period = (rate / MIN_HZ.min(FALLBACK_HZ)).ceil() as usize + 1;
+ let identity = SvfCoef::IDENTITY;
+ let mut e = Self {
+ rate,
+ channels,
+ settings: Settings::default(),
+ latency,
+ delay: vec![[0.; 2]; latency + 1],
+ delay_at: 0,
+ tracker,
+ pitch: PitchFollower::default(),
+ hz: 0.,
+ hz_coef: 1.,
+ countdown: 0,
+ edges: [0.; BANDS - 1],
+ coefs: Coefs {
+ low: [identity; BANDS - 1],
+ high: [identity; BANDS - 1],
+ all: [identity; BANDS - 1],
+ },
+ state: [Channel::default(); 2],
+ windows: std::array::from_fn(|_| Window::new(period)),
+ levels: [SILENT; BANDS],
+ gains: [0.; BANDS],
+ attack: [1.; BANDS],
+ release: [1.; BANDS],
+ smooth: coefficient(rate, 10.),
+ depth: 0.,
+ drive: 0.,
+ band_gains: [1.; BANDS],
+ output: 1.,
+ bypass: 0.,
+ fresh: true,
+ };
+ e.set_settings(Settings::default())?;
+ e.fresh = true;
+ e.retune();
+ Ok(e)
+ }
+ pub fn latency_samples(&self) -> u32 {
+ self.latency 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, s.smooth > 0.);
+ self.hz_coef = coefficient(self.rate, s.smooth / 3.);
+ if s.time != self.settings.time || self.fresh {
+ let scale = s.time * 0.01;
+ for b in 0..BANDS {
+ self.attack[b] = coefficient(self.rate, ATTACK_MS[b] * scale);
+ self.release[b] = coefficient(self.rate, RELEASE_MS[b] * scale);
+ }
+ }
+ if s.tops() != self.settings.tops() {
+ self.countdown = 0;
+ }
+ self.settings = s;
+ if std::mem::take(&mut self.fresh) {
+ self.depth = s.depth * 0.01;
+ self.drive = s.drive;
+ self.band_gains = s.gains.map(db_to_gain);
+ self.output = db_to_gain(s.output);
+ self.bypass = f32::from(s.bypass);
+ }
+ Ok(())
+ }
+ pub fn reset(&mut self) {
+ self.delay.fill([0.; 2]);
+ self.tracker.reset();
+ self.state = [Channel::default(); 2];
+ for w in &mut self.windows {
+ w.reset();
+ }
+ self.levels = [SILENT; BANDS];
+ self.gains = [0.; BANDS];
+ self.countdown = 0;
+ self.fresh = true;
+ }
+ 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);
+ }
+ /// Fundamental the edges follow: the tracked note, or the fallback before the first one.
+ fn root(&self) -> f32 {
+ if self.hz > 0. {
+ self.hz
+ } else {
+ FALLBACK_HZ
+ }
+ }
+ // Bands, splits and channels index several parallel arrays at once.
+ #[allow(clippy::needless_range_loop)]
+ fn retune(&mut self) {
+ let root = self.root();
+ let tops = self.settings.tops();
+ for s in 0..BANDS - 1 {
+ // Geometric middle: the harmonics on both sides of an edge lose the same amount.
+ let k = tops[s] as f32;
+ let hz = ((k * (k + 1.)).sqrt() * root).clamp(10., 0.45 * self.rate);
+ self.edges[s] = hz;
+ self.coefs.low[s] = SvfCoef::lowpass(self.rate, hz, Q);
+ self.coefs.high[s] = SvfCoef::highpass(self.rate, hz, Q);
+ self.coefs.all[s] = SvfCoef::allpass(self.rate, hz, Q);
+ }
+ let period = (self.rate / root).round() as usize;
+ for w in &mut self.windows {
+ w.set_len(period);
+ }
+ }
+ // Bands, splits and channels index several parallel arrays 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 (pitch, snap) = (&mut self.pitch, self.settings.snap);
+ self.tracker.process(mono, |e| pitch.event(snap, e));
+ let len = self.delay.len();
+ self.delay[self.delay_at] = x;
+ self.delay_at = (self.delay_at + 1) % len;
+ let y = self.delay[self.delay_at];
+
+ let (hz, jump) = self.pitch.step(self.hz_coef);
+ self.hz = hz;
+ if jump || self.countdown == 0 {
+ self.retune();
+ self.countdown = RETUNE;
+ }
+ self.countdown -= 1;
+
+ let s = self.settings;
+ approach(&mut self.depth, s.depth * 0.01, self.smooth);
+ approach(&mut self.drive, s.drive, self.smooth);
+ for b in 0..BANDS {
+ approach(&mut self.band_gains[b], db_to_gain(s.gains[b]), self.smooth);
+ }
+ approach(&mut self.output, db_to_gain(s.output), self.smooth);
+ approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
+
+ let mut bands = [[0.; BANDS]; 2];
+ for c in 0..self.channels {
+ bands[c] = self.state[c].split(&self.coefs, y[c]);
+ }
+ let (up, down) = (s.upward * 0.01, s.downward * 0.01);
+ let mut wet = [0.; 2];
+ for b in 0..BANDS {
+ let power = if self.channels == 2 {
+ 0.5 * (bands[0][b] * bands[0][b] + bands[1][b] * bands[1][b])
+ } else {
+ bands[0][b] * bands[0][b]
+ };
+ let level = 10. * (self.windows[b].push(power) + 1e-12).log10() + self.drive;
+ let coef = if level > self.levels[b] {
+ self.attack[b]
+ } else {
+ self.release[b]
+ };
+ self.levels[b] += (level - self.levels[b]) * coef;
+ self.gains[b] = compressor_gain(self.levels[b], up, down);
+ let g = 1. + self.depth * (db_to_gain(self.gains[b]) * self.band_gains[b] - 1.);
+ for c in 0..self.channels {
+ wet[c] += bands[c][b] * g;
+ }
+ }
+ let mut out = [0.; 2];
+ for c in 0..self.channels {
+ out[c] = wet[c] * self.output * (1. - self.bypass) + y[c] * self.bypass;
+ }
+ out
+ }
+ pub fn take_analyzer(&self) -> AnalyzerFrame {
+ let mut f = AnalyzerFrame {
+ hz: self.hz,
+ edges: self.edges,
+ levels: self.levels,
+ gains: self.gains,
+ latency: self.latency as u32,
+ sample_rate: self.rate,
+ ..AnalyzerFrame::default()
+ };
+ if self.tracker.sounding() && self.hz > 0. {
+ f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
+ }
+ f
+ }
+}
diff --git a/plugins/planish/core/src/settings.rs b/plugins/planish/core/src/settings.rs
new file mode 100644
index 0000000..f075847
--- /dev/null
+++ b/plugins/planish/core/src/settings.rs
@@ -0,0 +1,128 @@
+//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
+use serde::{Deserialize, Serialize};
+
+/// Bands: the fundamental, low harmonics, mid harmonics and the top.
+pub const BANDS: usize = 4;
+pub const GAIN_MIN: f32 = -24.;
+pub const GAIN_MAX: f32 = 24.;
+/// Highest harmonic a band edge can be set to.
+pub const TOP_MAX: u32 = 48;
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
+pub enum Source {
+ /// Pitch and note starts are detected from the incoming bass.
+ #[default]
+ Audio,
+ /// Notes come from MIDI note input.
+ Midi,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub struct Settings {
+ pub source: Source,
+ /// Highest harmonic in the bottom band. 1 keeps the fundamental on its own.
+ pub low: u32,
+ /// Highest harmonic in the second band.
+ pub mid: u32,
+ /// Highest harmonic in the third band; everything above is the top band.
+ pub high: u32,
+ /// Share of the compressed signal, per band, against the dry band.
+ pub depth: f32,
+ /// Scales every attack and release time.
+ pub time: f32,
+ /// Amount of upward compression: quiet bands are lifted.
+ pub upward: f32,
+ /// Amount of downward compression: loud bands are pushed down.
+ pub downward: f32,
+ /// Gain on the level the compressors see, not on the audio: moves the band levels against
+ /// the thresholds. More drive means more squash; at Depth 0 it changes nothing.
+ pub drive: f32,
+ /// Gain of each band after its compressor, bottom band first.
+ pub gains: [f32; BANDS],
+ /// Retune time when the pitch slides or bends.
+ pub smooth: f32,
+ /// Detection threshold in dBFS for note starts (Audio source).
+ pub gate: f32,
+ /// Round detected pitch to the nearest semitone. Off by default: the level window is one
+ /// period of the pitch in use, and a detuned note then gets a window that does not fit.
+ pub snap: bool,
+ pub output: f32,
+ pub bypass: bool,
+}
+impl Default for Settings {
+ fn default() -> Self {
+ Self {
+ source: Source::Audio,
+ low: 1,
+ mid: 4,
+ high: 16,
+ depth: 50.,
+ time: 100.,
+ upward: 100.,
+ downward: 100.,
+ drive: 0.,
+ gains: [0.; BANDS],
+ smooth: 10.,
+ gate: -42.,
+ snap: false,
+ output: 0.,
+ bypass: false,
+ }
+ }
+}
+impl Settings {
+ pub fn validate(&self) -> Result<(), &'static str> {
+ let finite = [
+ self.depth,
+ self.time,
+ self.upward,
+ self.downward,
+ self.drive,
+ self.smooth,
+ self.gate,
+ self.output,
+ ]
+ .iter()
+ .chain(self.gains.iter())
+ .all(|v| v.is_finite());
+ if !finite {
+ return Err("non-finite setting");
+ }
+ let ok = (1..=8).contains(&self.low)
+ && (2..=16).contains(&self.mid)
+ && (3..=TOP_MAX).contains(&self.high)
+ && self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
+ && (0.0..=100.).contains(&self.depth)
+ && (10.0..=1000.).contains(&self.time)
+ && (0.0..=100.).contains(&self.upward)
+ && (0.0..=100.).contains(&self.downward)
+ && (-24.0..=24.).contains(&self.drive)
+ && (0.0..=100.).contains(&self.smooth)
+ && (-60.0..=-20.).contains(&self.gate)
+ && (-24.0..=24.).contains(&self.output);
+ if ok {
+ Ok(())
+ } else {
+ Err("setting out of range")
+ }
+ }
+ /// Highest harmonic of the bottom three bands, in rising order. Host automation can set
+ /// the edges in any order, so a lower edge pushes the next one up instead of failing.
+ pub fn tops(&self) -> [u32; BANDS - 1] {
+ let low = self.low.clamp(1, 8);
+ let mid = self.mid.max(low + 1);
+ let high = self.high.max(mid + 1);
+ [low, mid, high]
+ }
+ /// Text for the harmonics of `band`, like "1", "2–4" or "17+".
+ pub fn band_label(&self, band: usize) -> String {
+ let t = self.tops();
+ let first = if band == 0 { 1 } else { t[band - 1] + 1 };
+ match band {
+ 3 => format!("{first}+"),
+ _ if first == t[band] => format!("{first}"),
+ _ => format!("{first}–{}", t[band]),
+ }
+ }
+}
diff --git a/plugins/planish/core/tests/dsp.rs b/plugins/planish/core/tests/dsp.rs
new file mode 100644
index 0000000..f9ea1d0
--- /dev/null
+++ b/plugins/planish/core/tests/dsp.rs
@@ -0,0 +1,309 @@
+use common_dsp::meter::HarmonicMeter;
+use common_dsp::Saw;
+use planish_core::{compressor_gain, Engine, Settings, Source, BANDS};
+use std::f32::consts::{PI, TAU};
+
+const RATE: f32 = 48000.;
+
+fn engine(s: Settings) -> (Engine, usize) {
+ let mut e = Engine::new(RATE, 2).unwrap();
+ e.set_settings(s).unwrap();
+ let l = e.latency_samples() as usize;
+ (e, l)
+}
+fn saw(hz: f32, seconds: f32, amp: f32) -> Vec {
+ let mut s = Saw::new(RATE, hz);
+ (0..(seconds * RATE) as usize)
+ .map(|_| s.sample() * amp)
+ .collect()
+}
+/// Sum of sines `(harmonic, amplitude)` on `hz`, with the phase in f64 so long signals stay
+/// clean.
+fn tones(hz: f32, seconds: f32, parts: &[(usize, f32)]) -> Vec {
+ (0..(seconds * RATE) as usize)
+ .map(|i| {
+ let t = i as f64 / RATE as f64;
+ parts
+ .iter()
+ .map(|&(k, a)| {
+ let phase = (TAU as f64 * hz as f64 * k as f64 * t) % (TAU as f64);
+ a * (phase as f32).sin()
+ })
+ .sum()
+ })
+ .collect()
+}
+fn sine(hz: f32, seconds: f32, amp: f32) -> Vec {
+ tones(hz, seconds, &[(1, amp)])
+}
+fn render(e: &mut Engine, x: &[f32]) -> Vec {
+ x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
+}
+/// Harmonic levels of the last complete window of `x`.
+fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
+ let mut m = HarmonicMeter::new(RATE, 85.);
+ for &v in x {
+ m.push(v, hz);
+ }
+ *m.levels()
+}
+fn rms_db(x: &[f32]) -> f32 {
+ let p: f32 = x.iter().map(|v| v * v).sum::() / x.len() as f32;
+ 10. * (p + 1e-20).log10()
+}
+fn uncompressed() -> Settings {
+ Settings {
+ depth: 0.,
+ ..Settings::default()
+ }
+}
+
+#[test]
+fn bands_sum_flat_without_compression() {
+ for hz in [35., 80., 250., 1000., 4000., 12000.] {
+ let x = sine(hz, 0.6, 0.5);
+ let (mut e, l) = engine(uncompressed());
+ let y = render(&mut e, &x);
+ let tail = y.len() / 2;
+ let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
+ assert!(d.abs() < 0.05, "{hz} Hz: {d:+.3} dB");
+ }
+}
+
+#[test]
+fn band_edges_sit_between_the_same_harmonics_on_every_note() {
+ // A saw with amplitude a has harmonic k at 2a/(πk). Band 1 holds harmonic 1 minus what
+ // its slope gives away; the split of the series between the bands is the same on every
+ // note.
+ let a = 0.5;
+ let fundamental = 10. * ((2. * a / PI).powi(2) / 2.).log10();
+ let mut first: Option<[f32; BANDS]> = None;
+ for hz in [43.65, 65.41, 130.81] {
+ let (mut e, _) = engine(uncompressed());
+ render(&mut e, &saw(hz, 1., a));
+ let f = e.take_analyzer();
+ assert!((f.hz - hz).abs() < 0.5, "{hz}: tracked {}", f.hz);
+ assert!((f.edges[0] - 2f32.sqrt() * hz).abs() < 1., "{:?}", f.edges);
+ assert!((f.edges[1] - 20f32.sqrt() * hz).abs() < 2., "{:?}", f.edges);
+ assert!(
+ (f.levels[0] - fundamental).abs() < 2.5,
+ "{hz}: {:?}",
+ f.levels
+ );
+ let levels = *first.get_or_insert(f.levels);
+ // The top band also holds the saw's own roll-off near Nyquist, which is not harmonic.
+ for (b, tolerance) in [0.4, 0.4, 0.4, 1.].into_iter().enumerate() {
+ let d = f.levels[b] - levels[b];
+ assert!(d.abs() < tolerance, "{hz}: {:?} vs {levels:?}", f.levels);
+ }
+ }
+}
+
+#[test]
+fn the_same_setting_changes_the_same_harmonics_on_every_note() {
+ let s = Settings {
+ depth: 100.,
+ ..Settings::default()
+ };
+ let notes = [43.65, 65.41, 98.];
+ let mut changes = Vec::new();
+ for hz in notes {
+ let x = saw(hz, 1.5, 0.5);
+ let (mut e, l) = engine(s);
+ let y = render(&mut e, &x);
+ let a = harmonics(&x[..x.len() - l], hz);
+ let b = harmonics(&y[l..], hz);
+ changes.push((0..12).map(|k| b[k] - a[k]).collect::>());
+ }
+ // Compression must actually happen for the comparison to mean anything.
+ assert!(changes[0][0] < -6., "{:?}", changes[0]);
+ for (n, c) in changes.iter().enumerate().skip(1) {
+ for k in 0..12 {
+ let d = c[k] - changes[0][k];
+ assert!(
+ d.abs() < 1.5,
+ "note {n} harmonic {}: {c:?} vs {:?}",
+ k + 1,
+ changes[0]
+ );
+ }
+ }
+}
+
+#[test]
+fn upward_lifts_a_quiet_band_and_downward_pushes_a_loud_one() {
+ // Loud fundamental in band 1, a quiet 8th harmonic in band 3.
+ let hz = 55.;
+ let x = tones(hz, 1.5, &[(1, 0.7), (8, 0.003)]);
+ let a = harmonics(&x, hz);
+ let run = |upward: f32, downward: f32| {
+ let s = Settings {
+ depth: 100.,
+ upward,
+ downward,
+ ..Settings::default()
+ };
+ let (mut e, l) = engine(s);
+ let y = render(&mut e, &x);
+ let b = harmonics(&y[l..], hz);
+ [b[0] - a[0], b[7] - a[7]]
+ };
+ let [body, top] = run(100., 0.);
+ assert!(body.abs() < 0.5 && top > 5., "upward: {body:+.2} {top:+.2}");
+ let [body, top] = run(0., 100.);
+ assert!(
+ body < -10. && top.abs() < 1.,
+ "downward: {body:+.2} {top:+.2}"
+ );
+}
+
+/// Level in dB of harmonic `k` of `hz` in `x`, over whole periods (Goertzel).
+fn partial(x: &[f32], hz: f32, k: usize) -> f32 {
+ let w = TAU as f64 * hz as f64 * k as f64 / RATE as f64;
+ let (mut re, mut im) = (0f64, 0f64);
+ for (i, &v) in x.iter().enumerate() {
+ re += v as f64 * (w * i as f64).cos();
+ im += v as f64 * (w * i as f64).sin();
+ }
+ let amp = 2. * (re * re + im * im).sqrt() / x.len() as f64;
+ 20. * (amp + 1e-12).log10() as f32
+}
+
+#[test]
+fn heavy_compression_does_not_ripple_at_the_fundamental() {
+ // A per-period level is steady on a steady note, so even fast, hard compression adds no
+ // harmonics to a sine. 40 Hz has a period of exactly 1200 samples; Snap is off so the
+ // window is the measured period, not the nearest semitone's.
+ let hz = 40.;
+ let x = sine(hz, 1.5, 0.5);
+ let distortion = |depth: f32| {
+ let s = Settings {
+ depth,
+ drive: 12.,
+ time: 10.,
+ snap: false,
+ ..Settings::default()
+ };
+ let (mut e, _) = engine(s);
+ let y = render(&mut e, &x);
+ let tail = &y[y.len() - 24000..];
+ let fundamental = partial(tail, hz, 1);
+ (2..6)
+ .map(|k| partial(tail, hz, k) - fundamental)
+ .fold(f32::MIN, f32::max)
+ };
+ let (dry, squashed) = (distortion(0.), distortion(100.));
+ assert!(squashed < -80., "{squashed:.1} dB (dry {dry:.1} dB)");
+}
+
+#[test]
+fn silence_and_noise_floors_are_not_lifted() {
+ assert_eq!(compressor_gain(-120., 1., 1.), 0.);
+ assert!(compressor_gain(-60., 1., 1.) > 10.);
+ let s = Settings {
+ depth: 100.,
+ ..Settings::default()
+ };
+ let x = saw(55., 1., 2e-5);
+ let (mut e, l) = engine(s);
+ let y = render(&mut e, &x);
+ let tail = y.len() / 2;
+ let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
+ assert!(d < 2., "{d:+.2} dB");
+}
+
+#[test]
+fn bypass_passes_the_delayed_input_exactly() {
+ let s = Settings {
+ depth: 100.,
+ bypass: true,
+ ..Settings::default()
+ };
+ let x = saw(55., 0.5, 0.5);
+ let (mut e, l) = engine(s);
+ let y = render(&mut e, &x);
+ for i in l..x.len() {
+ assert_eq!(y[i], x[i - l]);
+ }
+}
+
+#[test]
+fn note_changes_do_not_click() {
+ let s = Settings {
+ depth: 100.,
+ ..Settings::default()
+ };
+ let mut x = saw(43.65, 0.5, 0.5);
+ x.extend(saw(58.27, 0.5, 0.5));
+ x.extend(saw(87.31, 0.5, 0.5));
+ let (mut e, _) = engine(s);
+ let y = render(&mut e, &x);
+ let step = y
+ .windows(2)
+ .map(|w| (w[1] - w[0]).abs())
+ .fold(0f32, f32::max);
+ let in_step = x
+ .windows(2)
+ .map(|w| (w[1] - w[0]).abs())
+ .fold(0f32, f32::max);
+ assert!(y.iter().all(|v| v.is_finite()));
+ assert!(step < 2. * in_step, "{step} vs {in_step}");
+}
+
+#[test]
+fn midi_source_moves_the_edges_at_the_delayed_note_time() {
+ let s = Settings {
+ source: Source::Midi,
+ ..uncompressed()
+ };
+ let (mut e, l) = engine(s);
+ // 43.65 Hz is MIDI 29.
+ e.note_on(29);
+ render(&mut e, &saw(43.65, l as f32 / RATE - 0.002, 0.5));
+ assert_eq!(e.take_analyzer().hz, 0.);
+ render(&mut e, &saw(43.65, 0.01, 0.5));
+ let f = e.take_analyzer();
+ assert_eq!(f.note, Some(29));
+ assert!(
+ (f.edges[0] - 2f32.sqrt() * 43.65).abs() < 0.2,
+ "{:?}",
+ f.edges
+ );
+}
+
+#[test]
+fn edges_in_the_wrong_order_are_pushed_up() {
+ let s = Settings {
+ low: 4,
+ mid: 3,
+ high: 3,
+ ..Settings::default()
+ };
+ assert!(s.validate().is_ok());
+ assert_eq!(s.tops(), [4, 5, 6]);
+ assert_eq!(s.band_label(0), "1–4");
+ assert_eq!(s.band_label(1), "5");
+ assert_eq!(s.band_label(3), "7+");
+ let d = Settings::default();
+ let labels: Vec = (0..BANDS).map(|b| d.band_label(b)).collect();
+ assert_eq!(labels, ["1", "2–4", "5–16", "17+"]);
+}
+
+#[test]
+fn settings_are_validated_and_serialise() {
+ let s = Settings::default();
+ let json = serde_json::to_string(&s).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap(), s);
+ let mut bad = s;
+ bad.gains[2] = 40.;
+ assert!(bad.validate().is_err());
+ bad = s;
+ bad.low = 0;
+ assert!(bad.validate().is_err());
+ bad = s;
+ bad.time = f32::NAN;
+ assert!(bad.validate().is_err());
+ let mut e = Engine::new(RATE, 2).unwrap();
+ assert!(e.set_settings(bad).is_err());
+ assert_eq!(e.settings(), s);
+}
diff --git a/plugins/planish/core/tests/realtime.rs b/plugins/planish/core/tests/realtime.rs
new file mode 100644
index 0000000..c694a70
--- /dev/null
+++ b/plugins/planish/core/tests/realtime.rs
@@ -0,0 +1,71 @@
+use planish_core::{Engine, 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_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 % 4800 == 0 {
+ settings.gains[(i / 4800) % 4] = -6.;
+ settings.low = 1 + ((i / 4800) % 3) as u32;
+ settings.time = 10. + ((i / 4800) % 5) as f32 * 100.;
+ settings.depth = 100.;
+ settings.source = if i > 48000 {
+ Source::Midi
+ } else {
+ Source::Audio
+ };
+ engine.set_settings(settings).unwrap();
+ engine.note_on(30 + (i / 4800 % 12) as u8);
+ }
+ if i % 4800 == 2400 {
+ engine.note_off(30 + (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/planish/plugin/Cargo.lock b/plugins/planish/plugin/Cargo.lock
new file mode 100644
index 0000000..13d4e70
--- /dev/null
+++ b/plugins/planish/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 = "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 = "planish"
+version = "0.1.0"
+dependencies = [
+ "clap-sys",
+ "crossbeam-queue",
+ "nih_plug",
+ "nih_plug_egui",
+ "planish-core",
+ "planish-ui",
+ "serde_json",
+]
+
+[[package]]
+name = "planish-core"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "serde",
+]
+
+[[package]]
+name = "planish-ui"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "common-ui",
+ "egui",
+ "planish-core",
+]
+
+[[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/planish/plugin/Cargo.toml b/plugins/planish/plugin/Cargo.toml
new file mode 100644
index 0000000..e7177cb
--- /dev/null
+++ b/plugins/planish/plugin/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "planish"
+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" }
+planish-core = { path = "../core" }
+planish-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/planish/plugin/src/lib.rs b/plugins/planish/plugin/src/lib.rs
new file mode 100644
index 0000000..4ec022b
--- /dev/null
+++ b/plugins/planish/plugin/src/lib.rs
@@ -0,0 +1,185 @@
+//! CLAP audio effect. All DSP state is owned by the processing thread.
+mod params;
+use crossbeam_queue::ArrayQueue;
+use nih_plug::prelude::*;
+use nih_plug_egui::{create_egui_editor, egui};
+use params::{PlanishParams, FLOATS};
+use planish_core::{AnalyzerFrame, Engine};
+use std::sync::Arc;
+
+struct EditorData {
+ ui: planish_ui::State,
+ gestures: [bool; FLOATS],
+}
+pub struct Planish {
+ params: Arc,
+ analyzer: Arc>,
+ engine: Option,
+ publish_count: usize,
+ publish_interval: usize,
+}
+impl Default for Planish {
+ fn default() -> Self {
+ Self {
+ params: Arc::new(PlanishParams::default()),
+ analyzer: Arc::new(ArrayQueue::new(2)),
+ engine: None,
+ publish_count: 0,
+ publish_interval: 1600,
+ }
+ }
+}
+impl Plugin for Planish {
+ const NAME: &'static str = "Planish";
+ 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 analyzer = self.analyzer.clone();
+ create_egui_editor(
+ params.editor.clone(),
+ EditorData {
+ ui: planish_ui::State::default(),
+ gestures: [false; FLOATS],
+ },
+ |ctx, _| planish_ui::style(ctx),
+ move |ctx, setter, data| {
+ while let Some(frame) = analyzer.pop() {
+ data.ui.accept_frame(frame);
+ }
+ let before = params.settings();
+ let mut edited = before;
+ nih_plug_egui::resizable_window::ResizableWindow::new("planish-editor")
+ .min_size(egui::vec2(
+ planish_ui::WIDTH as f32 - 80.,
+ planish_ui::HEIGHT as f32 - 40.,
+ ))
+ .show(ctx, ¶ms.editor, |ui| {
+ planish_ui::show(ui, &mut edited, &mut data.ui)
+ });
+ params.apply(
+ setter,
+ &before,
+ &edited,
+ data.ui.gesture_active,
+ &mut data.gestures,
+ );
+ // Band meters follow the audio.
+ 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);
+ 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 {
+ self.analyzer.force_push(engine.take_analyzer());
+ self.publish_count = 0;
+ }
+ }
+ // The lookahead delay and compressor release must drain after input stops.
+ ProcessStatus::KeepAlive
+ }
+}
+impl ClapPlugin for Planish {
+ const CLAP_ID: &'static str = "io.narl.bass-foundry.planish";
+ const CLAP_DESCRIPTION: Option<&'static str> = Some(
+ "Upward and downward compression in bands set by harmonic number, the same on every note",
+ );
+ const CLAP_MANUAL_URL: Option<&'static str> = None;
+ const CLAP_SUPPORT_URL: Option<&'static str> = None;
+ const CLAP_FEATURES: &'static [ClapFeature] = &[
+ ClapFeature::AudioEffect,
+ ClapFeature::Compressor,
+ ClapFeature::Stereo,
+ ClapFeature::Mono,
+ ];
+}
+nih_export_clap!(Planish);
diff --git a/plugins/planish/plugin/src/params.rs b/plugins/planish/plugin/src/params.rs
new file mode 100644
index 0000000..64abbd9
--- /dev/null
+++ b/plugins/planish/plugin/src/params.rs
@@ -0,0 +1,192 @@
+use nih_plug::prelude::*;
+use nih_plug_egui::EguiState;
+use planish_core::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
+use std::sync::Arc;
+
+#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
+pub enum SourceParam {
+ #[id = "audio"]
+ Audio,
+ #[id = "midi"]
+ #[name = "MIDI"]
+ Midi,
+}
+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)
+}
+fn edge(name: &str, v: u32, min: i32, max: i32) -> IntParam {
+ IntParam::new(name, v as i32, IntRange::Linear { min, max })
+}
+/// Continuous controls, in the order of the gesture slots.
+pub const FLOATS: usize = 12;
+
+#[derive(Params)]
+pub struct PlanishParams {
+ #[persist = "editor"]
+ pub editor: Arc,
+ #[id = "source"]
+ pub source: EnumParam,
+ #[id = "depth"]
+ pub depth: FloatParam,
+ #[id = "time"]
+ pub time: FloatParam,
+ #[id = "upward"]
+ pub upward: FloatParam,
+ #[id = "downward"]
+ pub downward: FloatParam,
+ #[id = "drive"]
+ pub drive: FloatParam,
+ #[id = "gain-1"]
+ pub gain_1: FloatParam,
+ #[id = "gain-2"]
+ pub gain_2: FloatParam,
+ #[id = "gain-3"]
+ pub gain_3: FloatParam,
+ #[id = "gain-4"]
+ pub gain_4: FloatParam,
+ #[id = "smooth"]
+ pub smooth: FloatParam,
+ #[id = "gate"]
+ pub gate: FloatParam,
+ #[id = "output"]
+ pub output: FloatParam,
+ #[id = "low"]
+ pub low: IntParam,
+ #[id = "mid"]
+ pub mid: IntParam,
+ #[id = "high"]
+ pub high: IntParam,
+ #[id = "snap"]
+ pub snap: BoolParam,
+ #[id = "bypass"]
+ pub bypass: BoolParam,
+}
+impl Default for PlanishParams {
+ fn default() -> Self {
+ let d = Settings::default();
+ let gain = |n: usize| linear(&format!("Band {n} gain"), 0., GAIN_MIN, GAIN_MAX, " dB");
+ Self {
+ editor: EguiState::from_size(planish_ui::WIDTH, planish_ui::HEIGHT),
+ source: EnumParam::new("Pitch source", SourceParam::Audio),
+ depth: linear("Depth", d.depth, 0., 100., " %"),
+ time: linear("Time", d.time, 10., 1000., " %"),
+ upward: linear("Upward", d.upward, 0., 100., " %"),
+ downward: linear("Downward", d.downward, 0., 100., " %"),
+ drive: linear("Drive", d.drive, -24., 24., " dB"),
+ gain_1: gain(1),
+ gain_2: gain(2),
+ gain_3: gain(3),
+ gain_4: gain(4),
+ smooth: linear("Smooth", d.smooth, 0., 100., " ms"),
+ gate: linear("Gate", d.gate, -60., -20., " dB"),
+ output: linear("Output", d.output, -24., 24., " dB"),
+ low: edge("Low edge", d.low, 1, 8),
+ mid: edge("Mid edge", d.mid, 2, 16),
+ high: edge("High edge", d.high, 3, TOP_MAX as i32),
+ snap: BoolParam::new("Snap to semitone", d.snap),
+ bypass: BoolParam::new("Bypass", false).make_bypass(),
+ }
+ }
+}
+impl PlanishParams {
+ pub fn settings(&self) -> Settings {
+ let gains = [&self.gain_1, &self.gain_2, &self.gain_3, &self.gain_4];
+ Settings {
+ source: match self.source.value() {
+ SourceParam::Audio => Source::Audio,
+ SourceParam::Midi => Source::Midi,
+ },
+ low: self.low.value() as u32,
+ mid: self.mid.value() as u32,
+ high: self.high.value() as u32,
+ depth: self.depth.value(),
+ time: self.time.value(),
+ upward: self.upward.value(),
+ downward: self.downward.value(),
+ drive: self.drive.value(),
+ gains: std::array::from_fn::(|b| gains[b].value()),
+ smooth: self.smooth.value(),
+ gate: self.gate.value(),
+ snap: self.snap.value(),
+ output: self.output.value(),
+ bypass: self.bypass.value(),
+ }
+ }
+ fn floats(&self) -> [&FloatParam; FLOATS] {
+ [
+ &self.depth,
+ &self.time,
+ &self.upward,
+ &self.downward,
+ &self.drive,
+ &self.gain_1,
+ &self.gain_2,
+ &self.gain_3,
+ &self.gain_4,
+ &self.smooth,
+ &self.gate,
+ &self.output,
+ ]
+ }
+ fn values(s: &Settings) -> [f32; FLOATS] {
+ [
+ s.depth, s.time, s.upward, s.downward, s.drive, s.gains[0], s.gains[1], s.gains[2],
+ s.gains[3], s.smooth, s.gate, s.output,
+ ]
+ }
+ /// Pushes every changed editor value to the host. Continuous controls hold one gesture
+ /// while the pointer is down; switches and edges 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;
+ }
+ }
+ for (p, a, b) in [
+ (&self.low, old.low, new.low),
+ (&self.mid, old.mid, new.mid),
+ (&self.high, old.high, new.high),
+ ] {
+ if a != b {
+ setter.begin_set_parameter(p);
+ setter.set_parameter(p, b as i32);
+ setter.end_set_parameter(p);
+ }
+ }
+ for (p, a, b) in [
+ (&self.snap, old.snap, new.snap),
+ (&self.bypass, old.bypass, new.bypass),
+ ] {
+ if a != b {
+ setter.begin_set_parameter(p);
+ setter.set_parameter(p, b);
+ setter.end_set_parameter(p);
+ }
+ }
+ 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);
+ }
+ }
+}
diff --git a/plugins/planish/plugin/tests/host.rs b/plugins/planish/plugin/tests/host.rs
new file mode 100644
index 0000000..5554f81
--- /dev/null
+++ b/plugins/planish/plugin/tests/host.rs
@@ -0,0 +1,214 @@
+//! 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 = &planish::clap_entry;
+ assert!(entry.init.unwrap()(c"planish.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"Planish");
+ assert_eq!(
+ CStr::from_ptr((*descriptor).id),
+ c"io.narl.bass-foundry.planish"
+ );
+ 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;
+ // Depth 0 makes the processed signal a pure crossover sum: the tone must pass flat.
+ envelope.params.insert("depth".into(), ParamValue::F32(0.));
+ envelope
+ .params
+ .insert("gain-2".into(), ParamValue::F32(-6.));
+ envelope.params.insert("low".into(), ParamValue::I32(2));
+ envelope.params.insert("high".into(), ParamValue::I32(24));
+ envelope
+ .params
+ .insert("snap".into(), ParamValue::Bool(true));
+ 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;
+ // About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
+ assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{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 through the crossovers, at depth 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/planish/ui/Cargo.toml b/plugins/planish/ui/Cargo.toml
new file mode 100644
index 0000000..f598612
--- /dev/null
+++ b/plugins/planish/ui/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "planish-ui"
+version = "0.1.0"
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+egui = "0.31.1"
+planish-core = { path = "../core" }
+common-ui = { path = "../../common/ui" }
+common-dsp = { path = "../../common/dsp" }
diff --git a/plugins/planish/ui/src/lib.rs b/plugins/planish/ui/src/lib.rs
new file mode 100644
index 0000000..7e60570
--- /dev/null
+++ b/plugins/planish/ui/src/lib.rs
@@ -0,0 +1,507 @@
+//! Planish editor, shared by the CLAP wrapper and the headless renderer.
+use common_ui::*;
+use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
+use planish_core::{
+ AnalyzerFrame, Settings, Source, BANDS, DOWN_THRESHOLD, FALLBACK_HZ, FLOOR, GAIN_MAX, GAIN_MIN,
+ TOP_MAX, UP_MAX, UP_THRESHOLD,
+};
+
+pub use common_ui::style;
+pub const WIDTH: u32 = 1040;
+pub const HEIGHT: u32 = 650;
+
+pub struct State {
+ pub analyzer: AnalyzerFrame,
+ /// True while a pointer button is held, so knob drags become one host gesture.
+ pub gesture_active: bool,
+ pub help: String,
+ /// Band edge being dragged on the harmonic ruler.
+ dragging: Option,
+}
+impl Default for State {
+ fn default() -> Self {
+ Self {
+ analyzer: AnalyzerFrame::default(),
+ gesture_active: false,
+ help: DEFAULT_HELP.into(),
+ dragging: None,
+ }
+ }
+}
+const DEFAULT_HELP: &str =
+ "Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note, wherever they are in Hz.";
+impl State {
+ pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
+ let old = self.analyzer;
+ self.analyzer = frame;
+ // Levels fall smoothly so bars stay readable between frames.
+ for b in 0..BANDS {
+ self.analyzer.levels[b] = frame.levels[b].max(old.levels[b] - 3.);
+ }
+ }
+}
+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,
+ size: f32,
+ help: &str,
+) {
+ ui.vertical_centered(|ui| {
+ let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
+ hint(&r, state, help);
+ });
+}
+
+const GRID: Color32 = Color32::from_rgb(24, 26, 30);
+/// One colour per band, bottom band first.
+const BAND_COLORS: [Color32; BANDS] = [
+ Color32::from_rgb(255, 1, 96),
+ Color32::from_rgb(226, 120, 64),
+ Color32::from_rgb(126, 167, 216),
+ Color32::from_rgb(184, 192, 204),
+];
+/// Harmonics shown on the ruler: 1 to 64 on a log scale.
+const RULER_MAX: f32 = 64.;
+const LEVEL_TOP: f32 = 0.;
+const TOP: f32 = 330.;
+
+fn ruler_x(rect: Rect, harmonic: f32) -> f32 {
+ rect.left() + rect.width() * (harmonic.max(1.).log2() / RULER_MAX.log2())
+}
+fn x_ruler(rect: Rect, x: f32) -> f32 {
+ 2f32.powf((x - rect.left()) / rect.width() * RULER_MAX.log2())
+}
+fn level_y(rect: Rect, db: f32) -> f32 {
+ let t = (db - FLOOR) / (LEVEL_TOP - FLOOR);
+ rect.bottom() - rect.height() * t.clamp(0., 1.)
+}
+fn gain_y(rect: Rect, db: f32) -> f32 {
+ let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN);
+ rect.bottom() - rect.height() * t.clamp(0., 1.)
+}
+fn edge_mut(settings: &mut Settings, edge: usize) -> &mut u32 {
+ match edge {
+ 0 => &mut settings.low,
+ 1 => &mut settings.mid,
+ _ => &mut settings.high,
+ }
+}
+fn edge_range(edge: usize) -> (u32, u32) {
+ match edge {
+ 0 => (1, 8),
+ 1 => (2, 16),
+ _ => (3, TOP_MAX),
+ }
+}
+
+/// Harmonic ruler with draggable band edges, then one column per band with its level,
+/// thresholds and compressor gain.
+fn band_display(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
+ let size = Vec2::new(ui.available_width(), height);
+ let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
+ let p = ui.painter_at(outer);
+ p.rect_filled(outer, 2., SCREEN);
+ let ruler = Rect::from_min_max(
+ outer.left_top() + Vec2::new(16., 26.),
+ Pos2::new(outer.right() - 16., outer.top() + 62.),
+ );
+ let columns = Rect::from_min_max(
+ Pos2::new(outer.left() + 40., ruler.bottom() + 34.),
+ Pos2::new(outer.right() - 40., outer.bottom() - 34.),
+ );
+ let tops = settings.tops();
+ let f = state.analyzer;
+ let root = if f.hz > 0. { f.hz } else { FALLBACK_HZ };
+
+ // Ruler: band spans, harmonic ticks, and edges between harmonics.
+ let edge_at = |k: u32| ((k * (k + 1)) as f32).sqrt();
+ let mut from = 1f32;
+ for b in 0..BANDS {
+ let to = if b < BANDS - 1 {
+ edge_at(tops[b])
+ } else {
+ RULER_MAX
+ };
+ let span = Rect::from_min_max(
+ Pos2::new(ruler_x(ruler, from), ruler.top() + 14.),
+ Pos2::new(ruler_x(ruler, to), ruler.bottom()),
+ );
+ p.rect_filled(span, 1., BAND_COLORS[b].gamma_multiply(0.22));
+ p.text(
+ span.center_top() + Vec2::new(0., -2.),
+ egui::Align2::CENTER_BOTTOM,
+ format!("BAND {} · {}", b + 1, settings.band_label(b)),
+ FontId::proportional(10.),
+ BAND_COLORS[b],
+ );
+ from = to;
+ }
+ for k in 1..=RULER_MAX as u32 {
+ let x = ruler_x(ruler, k as f32);
+ let major = k.is_power_of_two() || k <= 8;
+ let tick = if major { 10. } else { 5. };
+ p.line_segment(
+ [
+ Pos2::new(x, ruler.bottom() - tick),
+ Pos2::new(x, ruler.bottom()),
+ ],
+ Stroke::new(1.0_f32, if major { MUTED } else { DIM }),
+ );
+ if major {
+ p.text(
+ Pos2::new(x, ruler.bottom() + 3.),
+ egui::Align2::CENTER_TOP,
+ format!("{k}"),
+ FontId::proportional(10.),
+ MUTED,
+ );
+ }
+ }
+ let nearest = |x: f32| {
+ (0..BANDS - 1)
+ .min_by(|&a, &b| {
+ let da = (ruler_x(ruler, edge_at(tops[a])) - x).abs();
+ let db = (ruler_x(ruler, edge_at(tops[b])) - x).abs();
+ da.total_cmp(&db)
+ })
+ .unwrap_or(0)
+ };
+ let hover_edge = response
+ .hover_pos()
+ .filter(|p| p.y < ruler.bottom() + 16.)
+ .map(|p| nearest(p.x));
+ if response.drag_started() {
+ state.dragging = response
+ .interact_pointer_pos()
+ .filter(|p| p.y < ruler.bottom() + 16.)
+ .map(|p| nearest(p.x));
+ }
+ if let (Some(edge), Some(pos)) = (state.dragging, response.interact_pointer_pos()) {
+ let (lo, hi) = edge_range(edge);
+ // The edge above harmonic k sits at √(k(k+1)), so k is the harmonic below the pointer.
+ let k = (x_ruler(ruler, pos.x) - 0.5)
+ .round()
+ .clamp(lo as f32, hi as f32) as u32;
+ *edge_mut(settings, edge) = k;
+ // Keep the order visible while dragging: the other edges make room.
+ let t = settings.tops();
+ settings.low = t[0];
+ settings.mid = t[1].min(16);
+ settings.high = t[2];
+ }
+ if !response.dragged() {
+ state.dragging = None;
+ }
+ for (e, &top) in settings.tops().iter().enumerate() {
+ let x = ruler_x(ruler, edge_at(top));
+ let active = state.dragging == Some(e) || hover_edge == Some(e);
+ p.line_segment(
+ [
+ Pos2::new(x, ruler.top() + 10.),
+ Pos2::new(x, ruler.bottom() + 2.),
+ ],
+ Stroke::new(
+ if active { 2.5_f32 } else { 1.5 },
+ if active { TEXT } else { STEEL },
+ ),
+ );
+ }
+
+ // Columns: level (steel) against the thresholds, gain (accent cut, blue lift).
+ for db in [0., -12., -24., -36., -48., -60., -72.] {
+ let y = level_y(columns, db);
+ p.line_segment(
+ [Pos2::new(columns.left(), y), Pos2::new(columns.right(), y)],
+ Stroke::new(1.0_f32, GRID),
+ );
+ p.text(
+ Pos2::new(outer.left() + 8., y),
+ egui::Align2::LEFT_CENTER,
+ format!("{db:.0}"),
+ FontId::proportional(10.),
+ DIM,
+ );
+ }
+ // Gain scale on the right, for the bars.
+ for db in [24., 12., 0., -12., -24.] {
+ p.text(
+ Pos2::new(outer.right() - 8., gain_y(columns, db)),
+ egui::Align2::RIGHT_CENTER,
+ if db == 0. {
+ "0".into()
+ } else {
+ format!("{db:+.0}")
+ },
+ FontId::proportional(10.),
+ if db == 0. { MUTED } else { DIM },
+ );
+ }
+ let col = columns.width() / BANDS as f32;
+ let depth = settings.depth * 0.01;
+ for b in 0..BANDS {
+ let x0 = columns.left() + col * b as f32;
+ let level = Rect::from_min_max(
+ Pos2::new(x0 + col * 0.14, level_y(columns, f.levels[b])),
+ Pos2::new(x0 + col * 0.44, columns.bottom()),
+ );
+ if level.height() > 0.5 {
+ p.rect_filled(level, 1., STEEL.gamma_multiply(0.8));
+ }
+ for (db, amount, color) in [
+ (DOWN_THRESHOLD, settings.downward, ACCENT),
+ (UP_THRESHOLD, settings.upward, FOCUS),
+ ] {
+ if amount > 0. {
+ let y = level_y(columns, db);
+ p.line_segment(
+ [Pos2::new(x0 + col * 0.08, y), Pos2::new(x0 + col * 0.5, y)],
+ Stroke::new(1.0_f32, color.gamma_multiply(0.8)),
+ );
+ }
+ }
+ // The same blend as the engine: Depth mixes the band with its compressed copy.
+ let wet = 10f32.powf((f.gains[b] + settings.gains[b]) / 20.);
+ let gain = 20. * (1. + depth * (wet - 1.)).max(1e-6).log10();
+ let zero = gain_y(columns, 0.);
+ let y = gain_y(columns, gain);
+ let bar = Rect::from_min_max(
+ Pos2::new(x0 + col * 0.56, zero.min(y)),
+ Pos2::new(x0 + col * 0.86, zero.max(y)),
+ );
+ p.line_segment(
+ [
+ Pos2::new(x0 + col * 0.52, zero),
+ Pos2::new(x0 + col * 0.9, zero),
+ ],
+ Stroke::new(1.0_f32, EDGE),
+ );
+ if bar.height() >= 1. {
+ p.rect_filled(bar, 1., if gain < 0. { ACCENT } else { FOCUS });
+ }
+ let hz = |k: f32| k * root;
+ let low = if b == 0 { 1. } else { edge_at(tops[b - 1]) };
+ let range = if b < BANDS - 1 {
+ format!("{:.0}–{:.0} Hz", hz(low), hz(edge_at(tops[b])))
+ } else {
+ format!("{:.0} Hz +", hz(low))
+ };
+ p.text(
+ Pos2::new(x0 + col * 0.5, columns.bottom() + 6.),
+ egui::Align2::CENTER_TOP,
+ format!("{} · {gain:+.1} dB", settings.band_label(b)),
+ FontId::proportional(11.),
+ BAND_COLORS[b],
+ );
+ p.text(
+ Pos2::new(x0 + col * 0.5, columns.bottom() + 20.),
+ egui::Align2::CENTER_TOP,
+ range,
+ FontId::proportional(10.),
+ MUTED,
+ );
+ }
+ p.text(
+ Pos2::new(outer.right() - 8., ruler.bottom() + 18.),
+ egui::Align2::RIGHT_TOP,
+ "LEVEL dB · GAIN dB",
+ FontId::proportional(11.),
+ MUTED,
+ );
+ p.text(
+ outer.left_top() + Vec2::new(8., 6.),
+ egui::Align2::LEFT_TOP,
+ "HARMONIC",
+ FontId::proportional(11.),
+ MUTED,
+ );
+ p.rect_stroke(
+ outer,
+ 2.,
+ Stroke::new(1.0_f32, EDGE),
+ egui::StrokeKind::Inside,
+ );
+ if let Some(e) = state.dragging.or(hover_edge) {
+ let k = tops[e];
+ state.help = format!(
+ "Edge between harmonics {k} and {}: {:.0} Hz on this note. Drag to move it.",
+ k + 1,
+ edge_at(k) * root,
+ );
+ } else if response.hovered() {
+ state.help = format!(
+ "Level plus Drive (steel) against the upward (blue) and downward (pink) thresholds; bar: band gain. Lift is at most {UP_MAX:.0} dB."
+ );
+ }
+}
+
+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());
+ ui.add_space(2.);
+ header(ui, "PLANISH", "HARMONIC OTT", |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();
+ 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, "PITCH FROM");
+ });
+ ui.add_space(6.);
+ let frame = state.analyzer;
+ ui.horizontal_top(|ui| {
+ panel(ui, |ui| {
+ ui.set_width(210.);
+ ui.set_min_height(TOP);
+ ui.vertical(|ui| note_panel(ui, settings, state, &frame));
+ });
+ ui.add_space(4.);
+ panel(ui, |ui| {
+ ui.set_width(ui.available_width());
+ band_display(ui, settings, state, 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));
+}
+
+/// Label, help text and band edges (highest harmonic of bands 1–3).
+type Split = (&'static str, &'static str, [u32; 3]);
+
+fn note_panel(
+ ui: &mut egui::Ui,
+ settings: &mut Settings,
+ state: &mut State,
+ frame: &AnalyzerFrame,
+) {
+ caption(ui, "NOTE");
+ let (name, hz) = match frame.note {
+ Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
+ _ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
+ _ => (
+ "—".into(),
+ format!("no note yet · edges on {FALLBACK_HZ:.0} Hz"),
+ ),
+ };
+ ui.label(
+ RichText::new(name)
+ .font(FontId::new(54., egui::FontFamily::Name("medium".into())))
+ .color(if frame.note.is_some() { TEXT } else { DIM }),
+ );
+ ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
+ ui.add_space(6.);
+ ui.label(
+ RichText::new(format!(
+ "{:.0} ms lookahead · {}",
+ frame.latency as f32 / frame.sample_rate * 1000.,
+ match settings.source {
+ Source::Audio => "detecting pitch",
+ Source::Midi => "following MIDI",
+ }
+ ))
+ .size(12.)
+ .color(MUTED),
+ );
+ ui.add_space(14.);
+ caption(ui, "SPLITS");
+ let splits: [Split; 4] = [
+ (
+ "CLASSIC",
+ "Fundamental alone, body 2–4, mids 5–16, top 17 and up.",
+ [1, 4, 16],
+ ),
+ (
+ "SUB + BODY",
+ "Harmonics 1–2 together, so the octave stays locked to the sub.",
+ [2, 6, 24],
+ ),
+ (
+ "TIGHT",
+ "Narrow low bands for close control of the first harmonics.",
+ [1, 2, 8],
+ ),
+ (
+ "WIDE TOP",
+ "One big mid band, a top that starts high: fizz only.",
+ [1, 3, 32],
+ ),
+ ];
+ ui.horizontal_wrapped(|ui| {
+ for (label, help, [low, mid, high]) in splits {
+ let on = settings.tops() == [low, mid, high];
+ let r = ui.selectable_label(on, RichText::new(label).size(12.));
+ if r.clicked() {
+ settings.low = low;
+ settings.mid = mid;
+ settings.high = high;
+ }
+ hint(&r, state, help);
+ }
+ });
+ ui.add_space(10.);
+ let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
+ if r.clicked() {
+ settings.snap = !settings.snap;
+ }
+ hint(
+ &r,
+ state,
+ "Round the detected pitch. Off keeps the level window on the real period, which is steadier on detuned patches.",
+ );
+}
+
+fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
+ let d = Settings::default();
+ ui.columns(12, |c| {
+ knob(&mut c[0], state, "Drive", &mut settings.drive, -24. ..=24., d.drive, " dB", 64.,
+ "Pushes the band levels against the thresholds: more drive, more squash. It does not change the level by itself.");
+ knob(&mut c[1], state, "Depth", &mut settings.depth, 0. ..=100., d.depth, " %", 64.,
+ "How much of the compression is applied. 0 % is a clean crossover sum.");
+ knob(&mut c[2], state, "Upward", &mut settings.upward, 0. ..=100., d.upward, " %", 64.,
+ "Lift for bands below the upward threshold: brings up quiet harmonics.");
+ knob(&mut c[3], state, "Downward", &mut settings.downward, 0. ..=100., d.downward, " %", 64.,
+ "Cut for bands above the downward threshold: holds loud harmonics down.");
+ knob(&mut c[4], state, "Time", &mut settings.time, 10. ..=1000., d.time, " %", 64.,
+ "Scales every attack and release. Lower bands are slower than higher ones.");
+ for b in 0..BANDS {
+ let name = format!("Band {}", b + 1);
+ let help = format!(
+ "Gain of band {} (harmonics {}) after its compressor, scaled by Depth.",
+ b + 1,
+ settings.band_label(b)
+ );
+ knob(&mut c[5 + b], state, &name, &mut settings.gains[b], GAIN_MIN..=GAIN_MAX,
+ d.gains[b], " dB", 54., &help);
+ }
+ knob(&mut c[9], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54.,
+ "Retune time for slides and bends. New notes always retune at once.");
+ knob(&mut c[10], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54.,
+ "Input level that counts as a note. Raise it if tails retrigger.");
+ knob(&mut c[11], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54.,
+ "Output trim.");
+ });
+}
diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock
index 7610c80..9a86dbd 100644
--- a/tools/editor-check/Cargo.lock
+++ b/tools/editor-check/Cargo.lock
@@ -164,6 +164,8 @@ dependencies = [
"lathe-ui",
"patina-core",
"patina-ui",
+ "planish-core",
+ "planish-ui",
"rivet-app",
"rivet-core",
"rivet-midi",
@@ -430,6 +432,24 @@ dependencies = [
"patina-core",
]
+[[package]]
+name = "planish-core"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "serde",
+]
+
+[[package]]
+name = "planish-ui"
+version = "0.1.0"
+dependencies = [
+ "common-dsp",
+ "common-ui",
+ "egui",
+ "planish-core",
+]
+
[[package]]
name = "png"
version = "0.18.1"
diff --git a/tools/editor-check/Cargo.toml b/tools/editor-check/Cargo.toml
index 7171e32..c918ceb 100644
--- a/tools/editor-check/Cargo.toml
+++ b/tools/editor-check/Cargo.toml
@@ -25,5 +25,7 @@ temper-core = { path = "../../plugins/temper/core" }
temper-ui = { path = "../../plugins/temper/ui" }
knurl-core = { path = "../../plugins/knurl/core" }
knurl-ui = { path = "../../plugins/knurl/ui" }
+planish-core = { path = "../../plugins/planish/core" }
+planish-ui = { path = "../../plugins/planish/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 3418123..21ca8a4 100644
--- a/tools/editor-check/src/main.rs
+++ b/tools/editor-check/src/main.rs
@@ -11,11 +11,13 @@ fn main() {
.map(|a| a[1].as_str())
.unwrap_or("rivet");
if ![
- "rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl",
+ "rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish",
]
.contains(&plugin)
{
- eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld or knurl");
+ eprintln!(
+ "Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl or planish"
+ );
std::process::exit(2);
}
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
@@ -26,7 +28,10 @@ fn main() {
let temper = plugin == "temper";
let weld = plugin == "weld";
let knurl = plugin == "knurl";
- let (width, height) = if knurl {
+ let planish = plugin == "planish";
+ let (width, height) = if planish {
+ (planish_ui::WIDTH, planish_ui::HEIGHT)
+ } else if knurl {
(knurl_ui::WIDTH, knurl_ui::HEIGHT)
} else if weld {
(weld_ui::WIDTH, weld_ui::HEIGHT)
@@ -165,6 +170,26 @@ fn main() {
}
knurl_state.accept_frame(engine.take_analyzer());
}
+ let mut planish_settings = planish_core::Settings {
+ depth: 80.,
+ gains: [0., 2., 3., -2.],
+ ..Default::default()
+ };
+ let mut planish_state = planish_ui::State::default();
+ if planish {
+ // A distorted F1 saw bass: loud body, thin top, so both directions work.
+ let mut engine = planish_core::Engine::new(48000., 2).unwrap();
+ engine.set_settings(planish_settings).unwrap();
+ let mut saw = common_dsp::Saw::new(48000., 43.65);
+ for i in 0..36000 {
+ let x = (saw.sample() * 1.6).tanh() * 0.4;
+ engine.process_sample([x, x]);
+ if i % 1600 == 0 {
+ planish_state.accept_frame(engine.take_analyzer());
+ }
+ }
+ planish_state.accept_frame(engine.take_analyzer());
+ }
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
@@ -294,7 +319,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
- if knurl {
+ if planish {
+ planish_ui::style(&ctx);
+ } else if knurl {
knurl_ui::style(&ctx);
} else if weld {
weld_ui::style(&ctx);
@@ -331,7 +358,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
- if knurl {
+ if planish {
+ planish_ui::show(ui, &mut planish_settings, &mut planish_state);
+ } else if knurl {
knurl_ui::show(ui, &mut knurl_settings, &mut knurl_state);
} else if weld {
weld_ui::show(ui, &mut weld_settings, &mut weld_state);
diff --git a/website/src/data/plugins.ts b/website/src/data/plugins.ts
index 392792a..29a0a6a 100644
--- a/website/src/data/plugins.ts
+++ b/website/src/data/plugins.ts
@@ -169,6 +169,21 @@ const COPY: Record = {
latency: '≈ 71 ms',
tracksNotes: true,
},
+ planish: {
+ role: 'Harmonic OTT',
+ metal: 'Planishing hammers a metal surface smooth and even.',
+ problem:
+ 'OTT splits its bands at fixed frequencies: harmonic 2 of F1 lands in the low band, harmonic 2 of C2 in the mids. The same setting squashes different harmonics on every note.',
+ points: [
+ 'Upward and downward compression in four bands.',
+ 'Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note.',
+ 'Level measured over one period of the note, so heavy settings do not ripple.',
+ 'Depth, Drive, Time and a gain per band. Pitch from the audio, or from MIDI.',
+ ],
+ loadOn: 'Bass, after the distortion',
+ latency: '≈ 67 ms',
+ tracksNotes: true,
+ },
};
export interface Plugin extends Copy {