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:
1 parent
22870127c8
commit
c06d193b18
29 files changed
+4707
-28
No files matched your search
@@ -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,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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+2381
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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, ¶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<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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user