feat(planish): add Planish harmonic OTT

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. A fixed-band
OTT puts harmonic 2 of F1 and of C2 in different bands.

- Three Linkwitz-Riley splits at sqrt(k(k+1)) x f0 with allpass
  compensation; at Depth 0 the bands sum within 0.05 dB.
- Band level is the RMS over one period of the note, so heavy settings
  do not ripple at f0: a 40 Hz sine at Drive +12 dB, Time 10 % gets no
  harmonics above -110 dB.
- At Depth 100 % on a saw, harmonics 1-12 change by the same amount on
  F1, C2 and G2 (within 0.2 dB).
- Depth, Drive (on the detector only), Upward, Downward, Time, a gain
  per band; edges Low 1-8, Mid 2-16, High 3-48 with four splits.
- Pitch from audio with about 67 ms lookahead, or from MIDI.
- Editor: harmonic ruler with draggable edges, level and gain per band.
- Catalog, workflows, editor-check, docs, signal chain and site copy.
This commit is contained in:
nvrl committed 2026-11-06 12:51:49 +01:00
1 parent 22870127c8
commit c06d193b18
29 files changed
+4707 -28

No files matched your search

+3 -1
View File
@@ -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:
+1 -1
View File
@@ -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
Generated
+19
View File
@@ -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"
+4 -2
View File
@@ -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
+10 -3
View File
@@ -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*.
<td><a href="docs/weld/README.md"><img src="docs/weld/assets/editor.png" alt="Weld editor"></a><br><b>Weld</b>: layer phase alignment</td>
<td><a href="docs/knurl/README.md"><img src="docs/knurl/assets/editor.png" alt="Knurl editor"></a><br><b>Knurl</b>: cycle texture</td>
</tr>
<tr>
<td><a href="docs/planish/README.md"><img src="docs/planish/assets/editor.png" alt="Planish editor"></a><br><b>Planish</b>: harmonic OTT</td>
<td></td>
</tr>
</table>
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/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld`, `knurl` and `planish`. Builds go to `dist/<target>/<Name>.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
+10 -6
View File
@@ -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.
+3 -2
View File
@@ -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.<product>`. Builds tagged before
this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different
+10 -8
View File
@@ -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
+14
View File
@@ -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.
+97
View File
@@ -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.
![Planish](assets/editor.png)
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+1
View File
@@ -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
+17
View File
@@ -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"
]
}
}
+12
View File
@@ -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 }
+412
View File
@@ -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<u8>,
/// 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<f32>,
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<Self, &'static str> {
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
}
}
+128
View File
@@ -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]),
}
}
}
+309
View File
@@ -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<f32> {
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<f32> {
(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<f32> {
tones(hz, seconds, &[(1, amp)])
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
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::<f32>() / 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::<Vec<f32>>());
}
// 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<String> = (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::<Settings>(&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);
}
+71
View File
@@ -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<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=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);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
+185
View File
@@ -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<PlanishParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
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<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
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, &params.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<Self>,
) -> 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<Self>,
) -> 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);
+192
View File
@@ -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<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[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::<f32, BANDS, _>(|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);
}
}
}
+214
View File
@@ -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<u8>) };
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<Vec<u8>>) };
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<u8> 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<Vec<u8>> 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()();
}
}
+11
View File
@@ -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" }
+507
View File
@@ -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<usize>,
}
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<f32>,
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.");
});
}
+20
View File
@@ -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"
+2
View File
@@ -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" }
+34 -5
View File
@@ -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);
+15
View File
@@ -169,6 +169,21 @@ const COPY: Record<string, Copy> = {
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 {