Compare commits

...
Author SHA1 Message Date
nvrl 340ff13342 chore: release Crucible 0.3.0, Patina 0.4.0 and Planish 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m22s
Check and Windows preview / check (crucible) (push) Successful in 1m55s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m41s
Check and Windows preview / check (patina) (push) Successful in 2m43s
Check and Windows preview / check (planish) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m42s
Check and Windows preview / check (temper) (push) Successful in 1m39s
Check and Windows preview / check (weld) (push) Successful in 2m40s
Check and Windows preview / previews (push) Successful in 1m37s
Check and Windows preview / windows (push) Successful in 2m34s
Independent plugin release / publish (push) Successful in 9s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m8s
- Crucible 0.3.0: Keep takes.
- Patina 0.4.0: Tone guard and the Bass line preset.
- Planish 0.1.0 is already at its first version.
- Update the current version in every plugin guide; several still named an
  older release.
2026-11-06 12:58:53 +01:00
nvrl c06d193b18 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.
2026-11-06 12:51:49 +01:00
nvrl 22870127c8 feat(common-dsp): second-order SVF allpass
SvfCoef::allpass has the phase of a Linkwitz-Riley split at the same
frequency, so a crossover tree can keep its lower bands in phase with
the upper ones. The test checks it against a real LR4 split sample by
sample and for flat magnitude.
2026-11-06 12:51:49 +01:00
nvrl 53bc26d14d feat(patina): Tone guard for bass lines
Patina detects the played note (30-500 Hz) and remembers, over about
4 s, how high each spectral bump is at a fixed frequency and relative
to the note. Bumps that follow the note (a boosted harmonic the patch
is built on) get less cut; bumps fixed in Hz (formants, room modes)
are cut as before. On a saw line F1-F2 with harmonic 6 boosted, the
cut on that harmonic drops from 5.78 dB to 0.44 dB at 100 %, while a
fixed formant at 1.5 kHz stays at 5.78 dB. One held note is cut as
normal, since the two cases cannot be told apart.

- New parameter tone-guard (0-100 %, default 0), appended at the end;
  0 % is the old behaviour and old settings load unchanged.
- New Bass line preset.
- Editor: Tone guard control, NOTE readout and a strip over the
  spectrum where cuts are held back.
- No added latency, no allocation on the audio thread; patina-core now
  depends on common-dsp.
2026-11-06 12:24:11 +01:00
nvrl 6d868a63c4 feat(crucible): keep every captured take
With Keep takes on (default), each capture is written to
<folder>/takes/ as a WAV named by time, tempo and length, so a take
is not lost when the next capture replaces it. Files are never
overwritten: a clash gets a numbered suffix. Saving runs off the UI
thread and the status line shows the path or the error.
2026-11-06 12:24:10 +01:00
nvrl 839e46aec9 docs: signal chain guide and suite audit
docs/chain.md gives the plugin order for a bass track and the bus, the
latency each plugin adds, and the measured octave error of downstream
note trackers after Knurl: from C2 up the tracker reads an octave low
about 85 % of the time, and timing jitter adds 5-25 false note starts
per second. Knurl and Anvil guides point there and recommend MIDI as
the pitch source after Knurl.

docs/ideas.md gains the production-flow audit, next steps for every
released plugin, new ideas (Forge, Planish, Quench, Scribe, Bevel,
Peen) and a build order.
2026-11-06 12:24:10 +01:00
60 changed files with 5441 additions and 43 deletions

No files matched your search

+3 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -62,6 +62,7 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/knurl.png --plugin knurl
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/planish.png --plugin planish
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -93,6 +94,7 @@ jobs:
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin knurl --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin planish --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*"]
permissions:
contents: read
Generated
+20
View File
@@ -304,6 +304,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
"serde_json",
@@ -326,6 +327,25 @@ dependencies = [
"patina-core",
]
[[package]]
name = "planish-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "planish-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"planish-core",
]
[[package]]
name = "primal-check"
version = "0.3.4"
+4 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"]
[workspace.package]
edition = "2021"
@@ -26,6 +26,8 @@ opt-level = 2
opt-level = 2
[profile.dev.package.knurl-core]
opt-level = 2
[profile.dev.package.planish-core]
opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
+11 -4
View File
@@ -28,17 +28,19 @@ 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
pitch from MIDI.
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
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
+5 -1
View File
@@ -1,6 +1,6 @@
# Anvil
Anvil gives every bass note the same solid sub. Current version **0.1.0**.
Anvil gives every bass note the same solid sub. Current version **0.2.0**.
In riddim and dubstep, each switch-up patch brings its own sub: a different level,
a different phase, sometimes two layers that cancel. The drop's weight jumps from
@@ -42,6 +42,10 @@ Other controls:
- **Keep** blends some of the original low band back under the new sub.
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
If Knurl is on the bass before the bus, use **MIDI**: Knurl's patterns make
detection read notes from C2 upwards an octave low. See
[Signal chain](../chain.md).
### Pitch from MIDI
Detection follows the audio by default. For heavy formant or FM basses where the
+53
View File
@@ -0,0 +1,53 @@
# Signal chain
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 → 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.
- **Weld after every plugin with latency on its track.** Weld can delay its own
layer but cannot move it earlier. If FL Studio does not compensate the
sidechain route, any latency added after Weld's input on the main layer makes
that layer late.
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.**
- **Crucible at the end of the chain you want to resample.**
## Knurl and pitch detection
Knurl's patterns make every second (or third) cycle quieter. That is a real
sub-octave in the signal, so a pitch detector after Knurl hears the note one
octave low, or with Triple an octave and a fifth low. Measured with the suite's
tracker on a saw bass:
- From C2 (65 Hz) upwards, Double, Fry (the default), Triple and Gallop make
the detector read the wrong octave for about 85 % of the note.
- 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, 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, 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.
+7
View File
@@ -1,5 +1,12 @@
# Crucible changelog
## 0.3.0
- **Keep takes** (on by default) saves every capture's loop to a `takes`
subfolder of the export folder at once. Takes are still not part of the
project, but a reload or a crash no longer loses them. Existing files are
never overwritten.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.crucible`. Hosts see this as a new
+5 -2
View File
@@ -1,7 +1,7 @@
# Crucible
Crucible melts the bars you just played into ready-to-use samples. Current version
**0.1.0**.
**0.3.0**.
Resampling is the core loop of riddim sound design: bounce, open in Edison, chop,
trim, declick, normalise, find the key, name, save, load into a sampler. Crucible
@@ -52,7 +52,10 @@ the playlist or Edison. **Open folder** shows the latest export in Explorer.
Crucible adds no latency and passes audio through untouched. Auditioned slices are
mixed into its output only while they play. The history holds 72 s, enough for
16 bars at 60 BPM. The newest eight takes stay in memory until the plugin is
removed; they are not saved with the project, so export what you want to keep.
removed; they are not saved with the project. With **Keep takes** on (the
default), every capture's loop is also written at once to
`takes/<date>_<time>_<tempo>bpm_<bars>bars.wav` in the export folder, so a reload
or a crash loses nothing. Existing files are never overwritten.
Bar alignment follows the host's song position and time signature. Root notes are
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 223 KiB

+3 -2
View File
@@ -25,7 +25,7 @@ Patina has `render` for offline WAV evaluation. The shared crates are:
- `common-ui`: palette, embedded fonts, header, panels, knobs, meters.
- `common-dsp`: filters, YIN pitch tracking, the note tracker used by Anvil,
Lathe, Temper and Knurl, 4× oversampling, meters and WAV files.
Lathe, Temper, Knurl and Planish, 4× oversampling, meters and WAV files.
The wrappers stay separate workspaces, each with its own lockfile, so the
NIH-plug Git dependency stays out of the root workspace. Host identities and
@@ -45,7 +45,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`.
`knurl`, `planish`.
`check-plugin.py` runs formatting, tests and clippy for one product's packages
and its wrapper, without compiling the other products. Run it for every product
@@ -144,6 +144,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Temper | (new) | `io.narl.bass-foundry.temper` |
| Weld | (new) | `io.narl.bass-foundry.weld` |
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
| Planish | (new) | `io.narl.bass-foundry.planish` |
CLAP IDs use the form `io.narl.bass-foundry.<product>`. Builds tagged before
this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different
+132
View File
@@ -35,6 +35,54 @@ Each entry names the problem first. Entries are grouped as released or idea. Nam
automation lanes or chopping.
15. There is no meter for texture. Two switch-ups can sound "the same" without
a clear reason, and generic analysers show only level and spectrum.
16. OTT and multiband compression use fixed band edges, so the same setting
squashes different harmonics on every note.
17. Riddim masters rely on clipping. A clipper that also clips the sub turns
the low end into distortion.
18. Resampled basslines have no MIDI, so tools that need the notes have nothing
to follow.
## Signal flow and suite audit (2026-11)
How the released plugins fit into one production:
```text
WRITE Rivet (motif → storyboard) ──MIDI──► synth
DESIGN synth → Lathe → Temper → Knurl → Patina
RESAMPLE Crucible (capture → slice → export) ──► sampler / wavetable synth ──► DESIGN
LAYER layer A + layer B → Weld (mids) → bass bus → Anvil (sub)
MIX gap: loudness, clipping, OTT, mono check, kick against sub, reference
```
Writing and sound design are covered. Mixing and mastering have only Anvil and
Patina. Findings from the audit:
- **Latency adds up.** A full chain (Knurl 71 + Temper 67 + Lathe 67 + Patina
about 45 + Anvil 80 ms) is about 330 ms. FL Studio compensates it for
playback, but a chain this long cannot be played live.
- **MIDI mode keeps the lookahead.** `Tracker::latency()` depends on the pitch
range only. With MIDI as the source, the note is known at its attack, so the
lookahead could be close to zero. This needs a latency change at runtime,
which must be checked in FL Studio first.
- **Knurl confuses every tracker after it.** Measured: Knurl output fed into
`TrackerConfig::BASS`. From C2 (65 Hz) upwards, Double, Fry (the default),
Gallop and Triple make the next tracker read the note one octave (Triple:
an octave and a fifth) too low for about 85 % of the note. Below C2 the false
sub-octave is under 30 Hz, out of range, so low notes are safe. Level and time
jitter also cause 5–25 false note starts per second. Anvil on the bus after
Knurl then plays the sub an octave low and restarts its phase. Until there is
a shared tracker: put Knurl after the other tracked plugins, and use MIDI as
the source for any tracked plugin after Knurl. See [Signal chain](chain.md).
- **Each tracked plugin needs its own MIDI route.** Four plugins in MIDI mode
are four FL routing setups.
- **Weld and delay compensation are untested together.** If the main layer's
chain has more latency than the sidechain layer and FL does not compensate
the sidechain route, the main layer arrives late, which an allpass cannot
fix. Put Weld after every plugin with latency on that track.
- **Crucible lost takes on reload.** Takes are not saved with the project.
Fixed: Keep takes writes every capture's loop to disk at once.
- **Settings handling differs.** Patina has presets and A/B; the others do not.
Lathe's drawn gains and Knurl's drawn pattern cannot be automated.
## Released
@@ -83,8 +131,92 @@ 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
hit and restarts its phase so kick and sub sum (problem 6). A sub shape from
sine to rounded triangle. A note attack counted in cycles.
- **Lathe**: A/B gain snapshots with one automatable Morph parameter. A
keyswitch note that selects the drawn shape (EQ switch-ups without
automation). A dynamic mode per harmonic.
- **Temper**: a harmonic split: harmonics 1–2 clean, 3 and up driven. More than
one Push band. A bias that changes per cycle from Knurl's cycle clock.
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
only. Drift (planned for 0.3).
- **Patina**: Tone guard (done, unreleased). MIDI as its pitch source. M/S
processing.
- **Weld**: alignment slots per note or per switch-up. The External
sender/receiver fallback if FL's sidechain does not arrive.
- **Crucible**: a switch-up slicer that cuts where the spectrum changes, for
legato basses. Optional retune of slices to one root. Reopening saved takes
from the `takes` folder in the take list.
- **Rivet**: more than two sound roles, one per switch-up patch. Its MIDI
output as the one note source for the tracked plugins.
- **Suite**: presets and A/B in `common-ui` for every plugin.
## Ideas
### Order
1. Small fixes from the audit. Done: chain-order guide
([Signal chain](chain.md)), Crucible Keep takes. Open: Weld test with delay
compensation (in FL), MIDI mode without lookahead (needs an FL check that a
latency change at runtime is accepted).
2. Patina pitch-aware mode. Done as **Tone guard** (unreleased): bumps that
follow the note are protected, bumps fixed in Hz are cut. Measured first:
Patina already cut fixed formants on every note, but it also cut a boosted
harmonic as hard (5.7 dB), at a position that moved with the note.
Open: MIDI as the pitch source.
3. Planish (done, 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.
Merges: Gauge, Loupe and Drop compare share one analysis core and one display,
so they become one plugin, **Assay**. The wobble sequencer, Damascus and Chase
are all "step grid, note selects the pattern", so they become one sequencer.
### Forge — one tracked channel strip (problems 9, 11, latency)
One tracker, one latency, and Lathe, Temper, Knurl and later Anneal as modules
in an order you choose. It removes the stacked lookahead, the trackers that
disagree (Knurl's sub-octave cannot confuse a tracker that reads the input
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.
### Quench — sub-safe clipper (problem 17)
Quenching hardens hot metal in one fast step. Quench clips only above a split
frequency and adds the clean sub back with its own headroom, so the master can
be clipped hard without distorting the low end. Pairs with Anvil's split.
### Scribe — audio to MIDI (problem 18)
A scribe marks lines on metal. Scribe runs the note tracker on a bass and
outputs MIDI notes and a `.mid` file. A resample from Crucible then gets its
notes back, so the other plugins can use MIDI as their source.
### Bevel — mono-safe harmonic widener (problem 5)
Pans harmonics of the note alternately (for example odd left, even right) with
complementary filters, so the mono sum stays flat. Pairs with Assay.
### Peen — note-start transient shaper
Peening hardens a surface with hammer blows. Attack and sustain shaping on
every note start, with times counted in cycles, so each switch-up gets the same
punch on every note. Could become part of Ballast.
### Ballast — switch-up loudness leveller (problem 2)
Measures short-term perceived loudness per note or per switch-up against a
+6 -1
View File
@@ -1,7 +1,7 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
version **0.1.0**.
version **0.2.0**.
Knurling presses a ridged pattern into metal. Riddim and dubstep basses get their
character the same way, from surface texture: rattle, growl, fry, grit. Usually
@@ -72,6 +72,11 @@ the waveform. With a stable pitch that is the same thing. With heavy pitch
modulation inside a note the pattern follows the tracked pitch, which can lag a
fast bend. Both channels get the same treatment, so the effect is mono-safe.
Knurl's patterns put a real sub-octave into the signal. A pitch detector after
Knurl (Anvil, Lathe or Temper in Audio mode) then reads notes from C2 upwards
an octave too low. Put Knurl after the other tracked plugins, or use MIDI as
their source. See [Signal chain](../chain.md).
## How it works
A note tracker (shared with Anvil, Lathe and Temper) detects pitch and note
+1 -1
View File
@@ -1,7 +1,7 @@
# Lathe
Lathe is an EQ whose bands sit on the harmonics of the note being played.
Current version **0.1.0**.
Current version **0.2.0**.
A normal EQ works in fixed frequencies. A cut at 130 Hz takes out the 3rd
harmonic of an F1 bass, the 2nd of a C2, and the fundamental of a C3, so the
+10
View File
@@ -1,5 +1,15 @@
# Patina changelog
## 0.4.0
- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and
remembers, over about four seconds, which bumps stay in Hz and which follow the
note. Bumps that follow the note (a harmonic the bass is built on) are cut
less; resonances fixed in Hz (formant, comb and filter rings) are still cut.
At 0 % Patina works exactly as before. The display shows the note and marks
the held-back region with a blue strip.
- Add the **Bass line** preset (Tone guard 80 %, Low 150 Hz).
## 0.3.0
- Change the CLAP ID to `io.narl.bass-foundry.patina`. Hosts see this as a new
+24 -2
View File
@@ -1,7 +1,7 @@
# Patina
Patina smooths a sound’s rough surface without erasing its character. Formerly
**Resonance Compressor**; current version **0.2.0**.
**Resonance Compressor**; current version **0.4.0**.
A mixing effect that finds harsh, ringing regions as they happen and pulls them
down, while plain harmonic tone passes. Independently designed in Rust; no claim
@@ -33,6 +33,27 @@ cuts, then use Delta to hear what is removed. Restrict Low/High frequency to kee
the source’s body out of reach. Attack and Release set how quickly cuts grow and
recover; Transient guard briefly relaxes cuts on attacks to keep punch.
### Tone guard for bass lines
On a bass line, a bump can mean two things. A resonance fixed in Hz (a formant,
a comb or filter ring) stays at the same frequency on every note: that is what
Patina should cut. A bump that follows the note (a harmonic the patch is built
on, or a Lathe boost) moves with every note: cutting it changes the patch
differently on each note.
**Tone guard** tells them apart. Patina detects the played note (30–500 Hz)
and remembers, over about four seconds, how high each bump was in Hz and how
high it was relative to the note. A bump that has stayed with the note more
than in Hz gets less cut; at 100 % none. A resonance fixed in Hz is cut as
before. It needs a few different notes before it can decide: on one held note
both look the same and Patina cuts as normal. The **NOTE** readout shows what
Patina follows, and a blue strip on top of the plot shows where cuts are held
back. Start from the **Bass line** preset. At 0 % Tone guard is off.
Measured on a saw line F1–F2 with Low at 150 Hz: a +12 dB boost on harmonic 6
is cut by 5.8 dB without Tone guard and by 0.4 dB at 100 %; a formant at 1.5 kHz
is cut by 5.8 dB either way.
Double-click the plot to add one of four focus points. Drag a point up to cut
more in that region, down to protect it. Wheel changes its width. Range handles
on the plot drag the processing limits.
@@ -76,7 +97,8 @@ The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstructi
the envelope/reference detector above, bounded soft-knee cuts, and temporal
smoothing. Original bin phases are retained. This is not a minimum-phase filter
bank, a live monitoring effect, a limiter, or an automatic separation of tone and
unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1.
unwanted resonance. No external sidechain, Learn or M/S. The pitch detector
serves Tone guard only.
## Build and offline evaluation
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 420 KiB

+14
View File
@@ -0,0 +1,14 @@
# Planish changelog
## 0.1.0
- Introduce Planish, upward and downward compression in four bands whose edges
are harmonic numbers of the played note.
- Band edges set by harmonic (Low 1–8, Mid 2–16, High 3–48), with splits
Classic, Sub + body, Tight and Wide top, or dragged on the harmonic ruler.
- Depth, Drive, Upward, Downward, Time and a gain per band.
- Band level measured over one period of the note, so heavy compression does
not ripple at the fundamental.
- Detect pitch and note starts from the audio with about 67 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the bands on a harmonic ruler and each band's level and gain.
+97
View File
@@ -0,0 +1,97 @@
# Planish
Planish is upward and downward compression (OTT) in four bands whose edges are
harmonic numbers of the played note. Current version **0.1.0**.
Planishing hammers a metal surface smooth and even. OTT does the same to a bass:
it pushes loud bands down and lifts quiet ones, and it is on almost every riddim
patch. A normal OTT splits the bands at fixed frequencies, though. A split at
88 Hz and 2.5 kHz puts harmonic 2 of F1 in the low band but harmonic 2 of C2 in
the mid band, so the same setting squashes different harmonics on every note.
Planish moves its band edges with the note: with the default split, band 1 is
the fundamental, band 2 harmonics 2–4, band 3 harmonics 5–16 and band 4
everything above, on F1 as on G2.
![Planish](assets/editor.png)
## Install on Windows
Copy `Planish.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Planish in a mixer slot of the bass. Keep the font
license with redistributed packages.
## Work with it
1. Play the bass. The note readout shows what Planish tracks. The ruler at the
top shows harmonics 1–64 and the four bands; the columns below show each
band's level (steel) and gain (pink for a cut, blue for a lift).
2. Choose a split, or drag an edge on the ruler:
- **Classic** (1, 2–4, 5–16, 17+, the default): fundamental on its own.
- **Sub + body** (1–2, 3–6, 7–24, 25+): the octave stays with the sub.
- **Tight** (1, 2, 3–8, 9+): close control of the first harmonics.
- **Wide top** (1, 2–3, 4–32, 33+): one big mid band, the top is fizz only.
3. Set the amount:
- **Depth** blends each band with its compressed copy. 0 % is a clean sum
of the bands; the default is 50 %.
- **Drive** moves the band levels against the thresholds: more drive, more
squash. It does not change the level by itself.
- **Upward** and **Downward** scale the two directions. Downward pushes a
band above −30 dB down at about 20:1; Upward lifts a band below −42 dB at
about 4:1, by at most 24 dB.
- **Time** scales every attack and release. Lower bands are slower: at
100 %, attack is 30, 15, 8 and 4 ms and release 200, 160, 120 and 90 ms
from band 1 to band 4.
4. **Band 1–4** set each band's gain after its compressor. Like the
compression, they are scaled by Depth.
**Output** trims the level. **Bypass** crossfades to the delayed dry signal.
### Pitch from MIDI
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
and when Planish comes after Knurl, select **MIDI** and route the bass's MIDI
notes to Planish. In FL Studio, set the bass channel's MIDI output port and
Planish's input port to the same number. **Gate** sets the input level that
counts as a note in AUDIO mode. **Snap to semitone** rounds the detected pitch;
it is off by default, because the level is measured over one period of the
pitch in use, and a detuned patch fits its real period better.
## Measured
- At Depth 0 the bands sum flat: sines from 35 Hz to 12 kHz come out within
0.05 dB.
- The same setting changes the same harmonics on every note. At Depth 100 %
on a saw bass, harmonic 1 is cut by 13.6 dB on F1, C2 and G2; harmonics 1–12
differ by less than 0.2 dB between the three notes.
- No ripple at the fundamental. A 40 Hz sine at Drive +12 dB and Time 10 % gets
no harmonics above −110 dB.
## Timing and limits
Planish delays the audio by about 67 ms of pitch-detection lookahead and
reports it to the host for delay compensation, so the edges are on the new note
when its attack leaves the plugin. Pitch range is 30–500 Hz, one note at a time.
Before the first note the edges sit on 55 Hz; between notes they hold the last
note.
An edge is a slope, not a wall. The crossovers are 24 dB per octave, so the
harmonics next to an edge partly reach the next band, by the same amount on
every note. Both channels get the same gain per band, so the stereo image does
not move. Quiet signals below about −84 dB are not lifted, so noise floors and
tails stay down.
Planish detects the pitch like Lathe and Temper. Put it before Knurl, or use
MIDI: see [Signal chain](../chain.md).
## How it works
A note tracker (shared with Anvil, Lathe, Temper and Knurl) detects pitch and
note starts ahead of the delayed audio. Three Linkwitz–Riley crossovers split
the signal at √(k(k+1)) × f0, halfway in log frequency between harmonic k and
k+1, and allpasses keep the lower bands in phase with the upper ones, so the
bands sum to an allpass. The crossovers retune every 16 samples and glide with
**Smooth** on bends. Each band's level is the RMS over exactly one period of
the note, so a steady note gives a steady level and the gain does not wobble
at f0. The level goes through attack and release smoothing, then a static
curve with an upward and a downward threshold sets the gain.
Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+3 -2
View File
@@ -8,13 +8,14 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| Plugin | Latest tag | Windows download |
| --- | --- | --- |
| Rivet | `rivet-v0.5.0` | `rivet-windows-x86_64-0.5.0.zip` |
| Patina | `patina-v0.3.0` | `patina-windows-x86_64-0.3.0.zip` |
| Patina | `patina-v0.4.0` | `patina-windows-x86_64-0.4.0.zip` |
| Anvil | `anvil-v0.2.0` | `anvil-windows-x86_64-0.2.0.zip` |
| Crucible | `crucible-v0.2.0` | `crucible-windows-x86_64-0.2.0.zip` |
| Crucible | `crucible-v0.3.0` | `crucible-windows-x86_64-0.3.0.zip` |
| Lathe | `lathe-v0.2.0` | `lathe-windows-x86_64-0.2.0.zip` |
| 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
+1 -1
View File
@@ -1,7 +1,7 @@
# Rivet
Rivet joins short motifs into connected musical phrases, like a fastener joining
individual pieces. Formerly **Motif Storyboard**; current version **0.4.0**.
individual pieces. Formerly **Motif Storyboard**; current version **0.5.0**.
A Rust composition companion for a producer developing their own melodic ideas.
+1 -1
View File
@@ -1,6 +1,6 @@
# Temper
Temper is a distortion that drives every note the same. Current version **0.1.0**.
Temper is a distortion that drives every note the same. Current version **0.2.0**.
A distortion with a fixed tone filter acts differently on each note: a lowpass
at 2 kHz lets through 45 harmonics of an F1 but only 22 of an F2, and
+1 -1
View File
@@ -1,7 +1,7 @@
# Weld
Weld fuses two bass layers so they add up instead of cancelling. Current version
**0.1.0**.
**0.2.0**.
Bass patches are layered: a sub, one or more mid growls, a top. Where the layers
overlap in the mids, they partly cancel. The causes are a polarity difference, a
+18
View File
@@ -26,6 +26,7 @@
},
"packages": [
"common-ui",
"common-dsp",
"patina-core",
"patina-ui",
"patina-render"
@@ -130,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"
]
}
}
+15
View File
@@ -70,6 +70,21 @@ impl SvfCoef {
m2: -1.,
}
}
/// Second-order allpass. With `q` = 1/√2 it has the phase of a Linkwitz–Riley crossover
/// built from two lowpass and two highpass stages at the same frequency, so it keeps
/// the bands of a crossover tree in phase.
pub fn allpass(rate: f32, hz: f32, q: f32) -> Self {
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: -2. * k,
m2: 0.,
}
}
pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / q.max(1e-3);
+25
View File
@@ -46,6 +46,31 @@ fn svf_bell_at_zero_db_is_an_exact_identity() {
}
}
#[test]
fn svf_allpass_matches_a_linkwitz_riley_split() {
let q = std::f32::consts::FRAC_1_SQRT_2;
let (low, high, all) = (
SvfCoef::lowpass(RATE, 300., q),
SvfCoef::highpass(RATE, 300., q),
SvfCoef::allpass(RATE, 300., q),
);
let mut s = [Svf::default(); 5];
let mut saw = Saw::new(RATE, 55.);
for _ in 0..9600 {
let x = saw.sample();
let l = s[0].process(&low, x);
let l = s[1].process(&low, l);
let h = s[2].process(&high, x);
let h = s[3].process(&high, h);
let a = s[4].process(&all, x);
assert!((l + h - a).abs() < 1e-4, "{} vs {a}", l + h);
}
let mut s = Svf::default();
for hz in [30., 300., 3000.] {
assert!(sine_gain(|x| s.process(&all, x), hz).abs() < 0.01);
}
}
#[test]
fn svf_survives_retuning_every_sample() {
let mut s = Svf::default();
+46
View File
@@ -34,6 +34,52 @@ pub fn stamp(unix: u64) -> String {
)
}
/// Writes a captured take's loop to `root/takes/` right away, so it survives a reload or a
/// crash. Never overwrites: a name already taken gets a counter. Returns the file written.
pub fn save_take(take: &Take, root: &Path, unix: u64) -> std::io::Result<PathBuf> {
let folder = root.join("takes");
std::fs::create_dir_all(&folder)?;
let base = format!(
"{}_{:.0}bpm_{}bar{}",
stamp(unix),
take.tempo(),
take.bars,
if take.bars == 1 { "" } else { "s" }
);
let wave = wav::Wave {
channels: 2,
rate: take.rate.round() as u32,
samples: take.audio.clone(),
root: None,
frame: None,
};
let bytes = wav::encode(&wave);
for n in 1..1000 {
let name = if n == 1 {
format!("{base}.wav")
} else {
format!("{base}_{n}.wav")
};
let path = folder.join(name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
std::io::Write::write_all(&mut file, &bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many takes with the same name",
))
}
pub fn export(
take: &Take,
settings: &Settings,
+1 -1
View File
@@ -5,7 +5,7 @@ mod player;
mod ring;
mod settings;
mod take;
pub use export::{export, stamp, Exported};
pub use export::{export, save_take, stamp, Exported};
pub use player::{Clip, Player, Queues};
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
pub use settings::{default_folder, Division, Mode, Settings};
+3
View File
@@ -64,6 +64,8 @@ pub struct Settings {
pub trim: bool,
/// Also write a wavetable from the selected slice.
pub wavetable: bool,
/// Save every captured take's loop to the `takes` subfolder at once.
pub keep_takes: bool,
/// Export folder; empty selects the default.
pub folder: String,
}
@@ -78,6 +80,7 @@ impl Default for Settings {
normalize: true,
trim: true,
wavetable: false,
keep_takes: true,
folder: String::new(),
}
}
+15
View File
@@ -231,6 +231,21 @@ fn export_writes_loop_slices_and_wavetable() {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_take_writes_the_loop_and_never_overwrites() {
let dir = std::env::temp_dir().join(format!("crucible-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let first = save_take(&take, &dir, 1_700_000_000).unwrap();
let second = save_take(&take, &dir, 1_700_000_000).unwrap();
assert!(first.ends_with("takes/2023-11-14_221320_120bpm_1bar.wav"));
assert!(second.ends_with("takes/2023-11-14_221320_120bpm_1bar_2.wav"));
let w = wav::decode(&std::fs::read(&first).unwrap()).unwrap();
assert_eq!(w.samples, take.audio);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn audition_plays_and_returns_clips_for_freeing() {
let q = Queues::default();
+1 -1
View File
@@ -450,7 +450,7 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crucible"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"crucible-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "crucible"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+38 -2
View File
@@ -1,8 +1,8 @@
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use crucible_core::{
capture, export, render, Division, Exported, Mode, Queues, Ring, Settings, Take, Transport,
OVERVIEW,
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, Take,
Transport, OVERVIEW,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
@@ -33,6 +33,8 @@ pub struct State {
names: Vec<String>,
/// Filled by the export thread when it finishes.
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
/// Filled by the take-saving thread when it finishes.
saving: Option<Arc<Mutex<Option<std::io::Result<std::path::PathBuf>>>>>,
last_export: Option<std::path::PathBuf>,
}
impl Default for State {
@@ -49,6 +51,7 @@ impl Default for State {
counter: 0,
names: Vec::new(),
job: None,
saving: None,
last_export: None,
}
}
@@ -136,6 +139,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
Err(e) => format!("Export failed: {e}"),
};
}
let saved = state
.saving
.as_ref()
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
if let Some(result) = saved {
state.saving = None;
state.status += &match result {
Ok(path) => format!(" Saved {}", path.display()),
Err(e) => format!(" Saving the take failed: {e}"),
};
}
ui.add_space(2.);
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
let db = 20. * state.peak.max(1e-6).log10();
@@ -204,6 +218,19 @@ fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, li
match capture(ring, settings.bars) {
Ok(take) => {
let synced = take.synced;
if settings.keep_takes {
let slot = Arc::new(Mutex::new(None));
let done = slot.clone();
let take = take.clone();
let folder = settings.folder();
std::thread::spawn(move || {
let result = save_take(&take, &folder, unix_now());
if let Ok(mut slot) = done.lock() {
*slot = Some(result);
}
});
state.saving = Some(slot);
}
state.add(take, settings);
state.status = if synced {
format!("Captured {} bars on the bar grid.", settings.bars)
@@ -481,6 +508,15 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
state,
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
);
let r = chip(ui, settings.keep_takes, "KEEP TAKES");
if r.clicked() {
settings.keep_takes = !settings.keep_takes;
}
hint(
&r,
state,
"Save every capture's loop to the takes folder at once, so a reload or crash loses nothing.",
);
ui.separator();
let take = state.take;
let slice = state.slice;
+1
View File
@@ -5,6 +5,7 @@ edition = "2021"
license = "ISC"
[dependencies]
common-dsp = { path = "../../common/dsp" }
rustfft = "6.4"
serde = { version = "1", features = ["derive"] }
+78 -2
View File
@@ -1,5 +1,7 @@
//! Independently designed spectral attenuation. No host or GUI dependencies.
mod pitch;
mod settings;
use pitch::PitchProbe;
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
pub use settings::{focus_offset, knee, Focus, Settings};
use std::{f32::consts::PI, sync::Arc};
@@ -22,6 +24,14 @@ const CLIP_DB: f32 = 2.;
const EMPTY_DB: f32 = 24.;
/// Steepest spectral tilt the reference follows, in dB per octave.
const MAX_TILT: f32 = 12.;
/// Tone guard memory: bump heights are averaged over about this many seconds.
const TONE_MEMORY: f32 = 4.;
/// Tone guard starts to protect a bump this much (as a share of its height) more consistent
/// relative to the note than in Hz, and protects it fully at `TONE_FULL`.
const TONE_START: f32 = 0.2;
const TONE_FULL: f32 = 0.6;
/// Bumps lower than this (dB) do not count for the tone guard comparison.
const TONE_MIN_DB: f32 = 1.;
fn grid_step() -> f32 {
1000f32.log2() / (GRID - 1) as f32
}
@@ -112,6 +122,10 @@ pub struct AnalyzerFrame {
pub reference: [f32; GRID],
pub reduction: [f32; GRID],
pub focus: [f32; GRID],
/// Share of the cut that Tone guard holds back, 0–1.
pub tone: [f32; GRID],
/// Fundamental the Tone guard follows, Hz; 0 when none.
pub pitch: f32,
pub input_peak: f32,
pub output_peak: f32,
pub guard: f32,
@@ -127,6 +141,8 @@ impl Default for AnalyzerFrame {
reference: [-120.; GRID],
reduction: [0.; GRID],
focus: [0.; GRID],
tone: [0.; GRID],
pitch: 0.,
input_peak: 0.,
output_peak: 0.,
guard: 0.,
@@ -139,6 +155,10 @@ impl Default for AnalyzerFrame {
pub fn frequency(index: usize) -> f32 {
20. * 1000f32.powf(index as f32 / (GRID - 1) as f32)
}
/// Grid position of a frequency, fractional.
pub fn cell(hz: f32) -> f32 {
(hz.max(20.) / 20.).log2() / 1000f32.log2() * (GRID - 1) as f32
}
#[derive(Clone, Copy, Default)]
pub struct Monitor {
pub delta: bool,
@@ -173,6 +193,12 @@ pub struct Engine {
envelope_radius: usize,
reference_radius: usize,
settings: Settings,
/// Tone guard: pitch of the analysis window and two slow maps of bump height, one fixed
/// in Hz and one relative to the fundamental (index = cell + GRID − fundamental cell).
probe: PitchProbe,
fixed: [f32; GRID],
relative: [f32; 2 * GRID],
tone: [f32; GRID],
frame: AnalyzerFrame,
fresh: bool,
fast: f32,
@@ -276,6 +302,10 @@ impl Engine {
envelope_radius: 0,
reference_radius: 0,
settings: Settings::default(),
probe: PitchProbe::new(rate),
fixed: [0.; GRID],
relative: [0.; 2 * GRID],
tone: [0.; GRID],
frame: AnalyzerFrame::default(),
fresh: false,
fast: 0.,
@@ -344,6 +374,10 @@ impl Engine {
}
self.reduction.fill(0.);
self.power = [[0.; GRID]; 2];
self.probe.reset();
self.fixed.fill(0.);
self.relative.fill(0.);
self.tone.fill(0.);
self.pos = 0;
self.hop_pos = 0;
self.fast = 0.;
@@ -403,6 +437,14 @@ impl Engine {
((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.);
self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp());
self.sample_power += energy;
if self.settings.tone_guard > 0. {
let mono = if self.channels == 2 {
0.5 * (self.input[0][self.pos] + self.input[1][self.pos])
} else {
self.input[0][self.pos]
};
self.probe.push(mono);
}
self.pos = (self.pos + 1) % self.n;
self.hop_pos += 1;
if self.hop_pos == self.hop {
@@ -433,6 +475,26 @@ impl Engine {
let mut clipped = [0f32; GRID];
let mut weight = [0f32; GRID];
let mut content = [false; GRID];
let mut bumps = [0f32; GRID];
// Tone guard: protect bumps that have followed the note more than they stayed in Hz.
let guard = self.settings.tone_guard / 100.;
let pitch = if guard > 0. && awake {
self.probe.estimate()
} else {
0.
};
let shift = cell(pitch).round() as usize;
for i in 0..GRID {
self.tone[i] = if pitch > 0. {
let rel = self.relative[i + GRID - shift];
let fixed = self.fixed[i];
let score = (rel - fixed) / rel.max(fixed).max(TONE_MIN_DB);
let t = ((score - TONE_START) / (TONE_FULL - TONE_START)).clamp(0., 1.);
t * t * (3. - 2. * t) * guard
} else {
0.
};
}
for c in 0..self.channels {
for i in 0..self.n {
self.spectra[c][i] =
@@ -524,15 +586,27 @@ impl Engine {
let excess = level - reference[i] + self.curve[i] - threshold;
// Only real content can ring: leakage far below nearby
// material is never cut.
let target = if awake && content[i] && level > -130. {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth)
let live = awake && content[i] && level > -130.;
let target = if live {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
} else {
0.
};
if live {
bumps[i] = bumps[i].max(level - reference[i]);
}
// Linked stereo: the stronger channel decides.
self.target[i] = self.target[i].max(target);
}
}
if pitch > 0. {
let alpha = 1. - (-frame_time / TONE_MEMORY).exp();
for i in 0..GRID {
self.fixed[i] += (bumps[i] - self.fixed[i]) * alpha;
let r = &mut self.relative[i + GRID - shift];
*r += (bumps[i] - *r) * alpha;
}
}
let cut = self.target;
smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1);
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp();
@@ -553,6 +627,7 @@ impl Engine {
* (1. - self.protect * self.settings.transient_guard / 100.))
.clamp(0., self.settings.depth);
self.frame.focus[i] = self.curve[i];
self.frame.tone[i] = self.tone[i];
}
for c in 0..self.channels {
for k in 0..=self.n / 2 {
@@ -581,6 +656,7 @@ impl Engine {
self.frame.input_peak = self.in_peak;
self.frame.output_peak = self.out_peak;
self.frame.guard = self.protect;
self.frame.pitch = pitch;
self.frame.sample_rate = self.rate;
self.frame.latency = self.n as u32;
self.frame.channels = self.channels;
+80
View File
@@ -0,0 +1,80 @@
//! Fundamental of the newest input, for Tone guard. Allocation-free after `new`.
use common_dsp::{hz_to_midi, Biquad, Yin};
const MIN_HZ: f32 = 30.;
const MAX_HZ: f32 = 500.;
/// Lowpass in front of detection, removing upper harmonics that confuse YIN.
const DETECT_LOWPASS: f32 = 1000.;
const DETECT_RATE: f32 = 6000.;
const CLARITY: f32 = 0.6;
/// Two estimates in a row must agree this closely (semitones) to count.
const AGREE: f32 = 0.5;
pub(crate) struct PitchProbe {
lowpass: [Biquad; 2],
decimate: usize,
count: usize,
rate: f32,
ring: Vec<f32>,
at: usize,
scratch: Vec<f32>,
yin: Yin,
last: f32,
}
impl PitchProbe {
pub fn new(rate: f32) -> Self {
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
let detect_rate = rate / decimate as f32;
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
let window = 2 * max_lag + 3;
let q = std::f32::consts::FRAC_1_SQRT_2;
Self {
lowpass: [Biquad::lowpass(rate, DETECT_LOWPASS, q); 2],
decimate,
count: 0,
rate: detect_rate,
ring: vec![0.; window],
at: 0,
scratch: vec![0.; window],
yin: Yin::new(max_lag + 2),
last: 0.,
}
}
pub fn push(&mut self, x: f32) {
let d = self.lowpass[0].process(x);
let d = self.lowpass[1].process(d);
self.count += 1;
if self.count >= self.decimate {
self.count = 0;
self.ring[self.at] = d;
self.at = (self.at + 1) % self.ring.len();
}
}
/// Fundamental in Hz of the newest input, or 0 when it is unclear or still changing.
pub fn estimate(&mut self) -> f32 {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.at + i) % n];
}
let hz = match self.yin.estimate(&self.scratch, self.rate, MIN_HZ, MAX_HZ) {
Some((hz, clarity)) if clarity >= CLARITY => hz,
_ => 0.,
};
let stable =
hz > 0. && self.last > 0. && (hz_to_midi(hz) - hz_to_midi(self.last)).abs() < AGREE;
self.last = hz;
if stable {
hz
} else {
0.
}
}
pub fn reset(&mut self) {
for f in &mut self.lowpass {
f.reset();
}
self.ring.fill(0.);
self.count = 0;
self.last = 0.;
}
}
+15 -1
View File
@@ -37,6 +37,10 @@ pub struct Settings {
pub output: f32,
pub bypass: bool,
pub focus: [Focus; 4],
/// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note
/// (a harmonic the sound is built on) while bumps fixed in Hz are still cut.
#[serde(default)]
pub tone_guard: f32,
}
impl Default for Settings {
fn default() -> Self {
@@ -53,6 +57,7 @@ impl Default for Settings {
output: 0.,
bypass: false,
focus: [Focus::default(); 4],
tone_guard: 0.,
}
}
}
@@ -69,6 +74,7 @@ impl Settings {
(self.high, 20., 20000.),
(self.mix, 0., 100.),
(self.output, -12., 12.),
(self.tone_guard, 0., 100.),
] {
if !v.is_finite() || !(low..=high).contains(&v) {
return Err("Invalid resonance control value");
@@ -102,11 +108,12 @@ impl Settings {
pub fn threshold(&self) -> f32 {
0.5 + self.selectivity * 0.05
}
pub const PRESETS: [&'static str; 4] = [
pub const PRESETS: [&'static str; 5] = [
"Gentle cleanup",
"Vocal harshness",
"Snare ring",
"Bright synth",
"Bass line",
];
pub fn preset(index: usize) -> Self {
let mut s = Self::default();
@@ -136,6 +143,13 @@ impl Settings {
s.sharpness = 25.;
s.selectivity = 45.;
}
4 => {
s.low = 150.;
s.high = 12000.;
s.sharpness = 50.;
s.selectivity = 45.;
s.tone_guard = 80.;
}
_ => {}
}
s
+110
View File
@@ -359,3 +359,113 @@ fn high_sample_rates_detect_the_same_bump() {
assert!(near >= 4. && away < 1., "{rate}: {near} / {away}");
}
}
/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2).
const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31];
/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`.
fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec<f32> {
let rate = 48000.;
let n = (rate * seconds) as usize;
let mut x = vec![0f32; n];
for k in 1..=(16000. / f0) as usize {
let f = k as f32 * f0;
let a = 0.08 * shape(k, f) / k as f32;
let w = TAU * f / rate;
for (i, v) in x.iter_mut().enumerate() {
*v += a * (w * i as f32 + k as f32).sin();
}
}
x
}
/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz`
/// (largest within ±3 grid cells) over the second half of the last note.
fn tone_cut(
guard: f32,
notes: &[f32],
rounds: usize,
shape: &dyn Fn(usize, f32) -> f32,
hz: &dyn Fn(f32) -> f32,
) -> f32 {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings {
tone_guard: guard,
low: 150.,
..Default::default()
})
.unwrap();
let last = *notes.last().unwrap();
let at = patina_core::cell(hz(last)).round() as usize;
let (mut sum, mut count) = (0., 0);
for round in 0..rounds {
for &f0 in notes {
let x = bass(f0, 0.4, shape);
let measure = round + 1 == rounds && f0 == last;
for (i, v) in x.iter().enumerate() {
e.process_sample([*v, 0.], Monitor::default());
if let Some(frame) = e.take_analyzer() {
if measure && i > x.len() / 2 {
sum += frame.reduction[at - 3..=at + 3]
.iter()
.fold(0f32, |m, v| m.max(*v));
count += 1;
}
}
}
}
}
sum / count as f32
}
fn harmonic_six(k: usize, _: f32) -> f32 {
if k == 6 {
4.
} else {
1.
}
}
fn formant(_: usize, f: f32) -> f32 {
let z = (f / 1500.).log2() / 0.15;
1. + 3. * (-z * z).exp()
}
#[test]
fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() {
let h6 = |f0: f32| 6. * f0;
let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6);
let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6);
assert!(
plain > 4.,
"without guard the boosted harmonic is cut: {plain}"
);
assert!(
guarded < 0.25 * plain,
"guard keeps the harmonic: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_still_cuts_resonances_fixed_in_hz() {
let at = |_: f32| 1500.;
let plain = tone_cut(0., &LINE, 3, &formant, &at);
let guarded = tone_cut(100., &LINE, 3, &formant, &at);
assert!(plain > 4., "the formant is cut: {plain}");
assert!(
guarded > 0.85 * plain,
"guard still cuts it: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_needs_different_notes_to_tell_bumps_apart() {
let h6 = |f0: f32| 6. * f0;
let one = [LINE[2]];
let plain = tone_cut(0., &one, 10, &harmonic_six, &h6);
let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6);
assert!(
(guarded - plain).abs() < 0.5,
"one held note gives no evidence: {guarded} vs {plain}"
);
}
#[test]
fn old_settings_without_tone_guard_still_load() {
let mut json = serde_json::to_value(Settings::default()).unwrap();
json.as_object_mut().unwrap().remove("tone_guard");
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.tone_guard, 0.);
}
+7 -1
View File
@@ -35,6 +35,7 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator;
fn processing_automation_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::preset(1);
settings.tone_guard = 80.;
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..48000 {
@@ -45,7 +46,12 @@ fn processing_automation_and_analysis_do_not_allocate_or_free() {
settings.low = 120. + (i % 100) as f32;
engine.set_settings(settings).unwrap();
}
std::hint::black_box(engine.process_sample([0.3, -0.2], Monitor { delta: i > 24000 }));
// A pitched input, so Tone guard estimates the pitch and updates its maps.
let x = 0.3
* (i as f32 * std::f32::consts::TAU * 55. / 48000.)
.sin()
.signum();
std::hint::black_box(engine.process_sample([x, -x], Monitor { delta: i > 24000 }));
std::hint::black_box(engine.take_analyzer());
}
engine.reset();
+6 -1
View File
@@ -306,6 +306,10 @@ dependencies = [
"memchr",
]
[[package]]
name = "common-dsp"
version = "0.1.0"
[[package]]
name = "common-ui"
version = "0.1.0"
@@ -1419,7 +1423,7 @@ dependencies = [
[[package]]
name = "patina"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
@@ -1434,6 +1438,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
+8 -2
View File
@@ -62,6 +62,8 @@ pub struct PatinaParams {
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "tone-guard"]
pub tone_guard: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(array, group = "Focus")]
@@ -82,6 +84,7 @@ impl Default for PatinaParams {
high: log("High frequency", d.high, 21., 20000., " Hz"),
mix: linear("Mix", 100., 0., 100., " %"),
output: linear("Output", 0., -12., 12., " dB"),
tone_guard: linear("Tone guard", d.tone_guard, 0., 100., " %"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
focus: std::array::from_fn(|_| FocusParams::default()),
}
@@ -116,6 +119,7 @@ impl PatinaParams {
&self.focus[3].frequency,
&self.focus[3].width,
&self.focus[3].sensitivity,
&self.tone_guard,
]
}
pub fn settings(&self) -> Settings {
@@ -137,6 +141,7 @@ impl PatinaParams {
width: self.focus[i].width.value(),
sensitivity: self.focus[i].sensitivity.value(),
}),
tone_guard: self.tone_guard.value(),
};
// Independent automation of bounds may cross them; preserve a valid effective range.
if s.low >= s.high {
@@ -145,8 +150,8 @@ impl PatinaParams {
s
}
}
/// Host float parameters: ten main controls, then four per focus point.
pub const COUNT: usize = 26;
/// Host float parameters: ten main controls, four per focus point, then Tone guard.
pub const COUNT: usize = 27;
pub fn values(s: &Settings) -> [f32; COUNT] {
let mut v = [0.; COUNT];
v[..10].copy_from_slice(&[
@@ -169,5 +174,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] {
s.focus[i].sensitivity,
]);
}
v[26] = s.tone_guard;
v
}
+51 -3
View File
@@ -43,7 +43,9 @@ impl State {
self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]);
self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]);
self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]);
self.analyzer.tone[i] += 0.5 * (frame.tone[i] - self.analyzer.tone[i]);
}
self.analyzer.pitch = frame.pitch;
self.analyzer.focus = frame.focus;
self.analyzer.input_peak = frame.input_peak;
self.analyzer.output_peak = frame.output_peak;
@@ -59,6 +61,21 @@ impl State {
}
}
}
/// Note name of a frequency, or a dash when there is none.
fn note_name(hz: f32) -> String {
if hz <= 0. {
return "—".into();
}
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let n = (69. + 12. * (hz / 440.).log2()).round() as i32;
format!(
"{}{}",
NAMES[n.rem_euclid(12) as usize],
n.div_euclid(12) - 1
)
}
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
@@ -99,6 +116,7 @@ fn control(
let defaults = Settings::default();
*value = match name {
"Transient guard" => defaults.transient_guard,
"Tone guard" => defaults.tone_guard,
"Attack" => defaults.attack,
"Release" => defaults.release,
"Low frequency" => defaults.low,
@@ -170,7 +188,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.separator();
egui::ComboBox::from_id_salt("resonance-preset")
.selected_text(
(0..4)
(0..Settings::PRESETS.len())
.find(|i| *settings == Settings::preset(*i))
.map_or("Custom", |i| Settings::PRESETS[i]),
)
@@ -209,6 +227,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
format!("{:>3.0} %", state.analyzer.guard * 100.),
MUTED,
);
if settings.tone_guard > 0. {
readout(ui, "NOTE", note_name(state.analyzer.pitch), FOCUS);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
caption(
ui,
@@ -368,9 +389,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
panel(ui, |ui| {
ui.set_width(ui.available_width());
egui::Grid::new("engine-controls")
.num_columns(3)
.num_columns(4)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 32.) / 3.)
.min_col_width((ui.available_width() - 48.) / 4.)
.show(ui, |ui| {
control(
ui,
@@ -402,6 +423,16 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Relaxes cuts on attacks to keep punch. Turn down to catch short rings.",
state,
);
control(
ui,
"Tone guard",
&mut settings.tone_guard,
0. ..=100.,
" %",
false,
"Keeps bumps that follow the played note (the harmonics a bass is built on) and still cuts rings fixed in Hz. Needs a few different notes to tell them apart.",
state,
);
ui.end_row();
});
ui.add_space(6.);
@@ -622,6 +653,23 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
fill[1..=GRID].to_vec(),
Stroke::new(1.6_f32, ACCENT),
));
// Tone guard: a strip along the top where cuts are held back because the bump follows the note.
if settings.tone_guard > 0. {
let mut strip = egui::Mesh::default();
for i in 0..GRID {
let xx = x(frequency(i));
let alpha = (state.analyzer.tone[i].clamp(0., 1.) * 170.) as u8;
let color = Color32::from_rgba_unmultiplied(FOCUS.r(), FOCUS.g(), FOCUS.b(), alpha);
strip.colored_vertex(Pos2::new(xx, r.top()), color);
strip.colored_vertex(Pos2::new(xx, r.top() + 8.), color);
if i > 0 {
let j = (i * 2) as u32;
strip.add_triangle(j - 2, j - 1, j);
strip.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(strip));
}
let center = r.center().y;
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
let points: Vec<_> = (0..GRID)
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "planish-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+412
View File
@@ -0,0 +1,412 @@
//! Upward and downward compression in four bands whose edges are harmonic numbers of the
//! played note, so the same setting squashes the same harmonics on every note.
//!
//! The crossovers are Linkwitz–Riley splits retuned to the tracked fundamental, with allpass
//! compensation, so the bands sum flat. An edge sits halfway (in log frequency) between two
//! harmonics. It is a slope, not a wall: neighbours of an edge partly reach the next band, by
//! the same amount on every note. Each band's level is measured over exactly one period
//! of the note: a steady note gives a steady level, and the gain does not ripple at the
//! fundamental. The audio path is delayed by the tracker's lookahead, so the edges are on the
//! new note when its attack leaves the plugin.
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Fundamental the edges sit on before the first note.
pub const FALLBACK_HZ: f32 = 55.;
/// Band levels are RMS over one period: a sine with peak 1 reads −3 dB.
pub const DOWN_THRESHOLD: f32 = -30.;
pub const UP_THRESHOLD: f32 = -42.;
/// Gain change per dB beyond the threshold at 100 %: about 20:1 downward, 4:1 upward.
const DOWN_SLOPE: f32 = 0.95;
const UP_SLOPE: f32 = 0.75;
/// Most lift a band can get.
pub const UP_MAX: f32 = 24.;
/// Below this level the lift fades out, so silence and noise floors are not raised.
pub const FLOOR: f32 = -84.;
const FLOOR_FADE: f32 = 12.;
/// Attack and release at Time 100 %, bottom band first: lower bands move slower.
pub const ATTACK_MS: [f32; BANDS] = [30., 15., 8., 4.];
pub const RELEASE_MS: [f32; BANDS] = [200., 160., 120., 90.];
/// Filter coefficients are recomputed this often, in samples.
const RETUNE: usize = 16;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const SILENT: f32 = -120.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the edges are tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Crossover frequencies in Hz between the bands.
pub edges: [f32; BANDS - 1],
/// Band level plus Drive, in dB RMS, as the compressors see it.
pub levels: [f32; BANDS],
/// Gain of each compressor in dB at full depth, before the band gains.
pub gains: [f32; BANDS],
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
edges: [0.; BANDS - 1],
levels: [SILENT; BANDS],
gains: [0.; BANDS],
latency: 0,
sample_rate: 48000.,
}
}
}
/// Gain in dB for a band level in dB, at the given upward and downward amounts (0–1).
pub fn compressor_gain(level: f32, upward: f32, downward: f32) -> f32 {
let down = if level > DOWN_THRESHOLD {
-(level - DOWN_THRESHOLD) * DOWN_SLOPE * downward
} else {
0.
};
let up = if level < UP_THRESHOLD {
let fade = ((level - FLOOR) / FLOOR_FADE).clamp(0., 1.);
let fade = fade * fade * (3. - 2. * fade);
((UP_THRESHOLD - level) * UP_SLOPE * upward).min(UP_MAX) * fade
} else {
0.
};
down + up
}
/// Mean of the last `n` values, with `n` changed only after a full window so the running sum
/// can be refreshed in the same step. Allocation-free after `new`.
struct Window {
ring: Vec<f32>,
at: usize,
n: usize,
next: usize,
count: usize,
sum: f32,
}
impl Window {
fn new(capacity: usize) -> Self {
Self {
ring: vec![0.; capacity + 1],
at: 0,
n: 1,
next: 1,
count: 0,
sum: 0.,
}
}
fn set_len(&mut self, n: usize) {
self.next = n.clamp(1, self.ring.len() - 1);
}
fn push(&mut self, v: f32) -> f32 {
let cap = self.ring.len();
self.sum += v - self.ring[(self.at + cap - self.n) % cap];
self.ring[self.at] = v;
self.at = (self.at + 1) % cap;
self.count += 1;
if self.count >= self.n {
// Exact sum over the new length: no drift, and a length change takes effect here.
self.count = 0;
self.n = self.next;
self.sum = (1..=self.n)
.map(|i| self.ring[(self.at + cap - i) % cap])
.sum();
}
self.sum.max(0.) / self.n as f32
}
fn reset(&mut self) {
self.ring.fill(0.);
self.sum = 0.;
self.count = 0;
}
}
/// Filter state of one channel: three LR4 splits and the allpasses that keep the lower bands
/// in phase with the upper ones.
#[derive(Clone, Copy, Default)]
struct Channel {
low: [[Svf; 2]; BANDS - 1],
high: [[Svf; 2]; BANDS - 1],
/// Band 1 through the allpasses of splits 2 and 3, band 2 through split 3.
phase: [Svf; 3],
}
impl Channel {
// Bands, splits and channels index several parallel arrays at once.
#[allow(clippy::needless_range_loop)]
fn split(&mut self, c: &Coefs, x: f32) -> [f32; BANDS] {
let mut rest = x;
let mut bands = [0.; BANDS];
for s in 0..BANDS - 1 {
let l = self.low[s][0].process(&c.low[s], rest);
bands[s] = self.low[s][1].process(&c.low[s], l);
let h = self.high[s][0].process(&c.high[s], rest);
rest = self.high[s][1].process(&c.high[s], h);
}
bands[BANDS - 1] = rest;
let b0 = self.phase[0].process(&c.all[1], bands[0]);
bands[0] = self.phase[1].process(&c.all[2], b0);
bands[1] = self.phase[2].process(&c.all[2], bands[1]);
bands
}
}
#[derive(Clone, Copy)]
struct Coefs {
low: [SvfCoef; BANDS - 1],
high: [SvfCoef; BANDS - 1],
all: [SvfCoef; BANDS - 1],
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
countdown: usize,
edges: [f32; BANDS - 1],
coefs: Coefs,
state: [Channel; 2],
windows: [Window; BANDS],
levels: [f32; BANDS],
gains: [f32; BANDS],
attack: [f32; BANDS],
release: [f32; BANDS],
smooth: f32,
depth: f32,
drive: f32,
band_gains: [f32; BANDS],
output: f32,
bypass: f32,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
/// One-pole step that lands exactly on the target, so switched states end bit-exact.
fn approach(value: &mut f32, target: f32, coef: f32) {
*value += (target - *value) * coef;
if (target - *value).abs() < 1e-6 {
*value = target;
}
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
let period = (rate / MIN_HZ.min(FALLBACK_HZ)).ceil() as usize + 1;
let identity = SvfCoef::IDENTITY;
let mut e = Self {
rate,
channels,
settings: Settings::default(),
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
tracker,
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
countdown: 0,
edges: [0.; BANDS - 1],
coefs: Coefs {
low: [identity; BANDS - 1],
high: [identity; BANDS - 1],
all: [identity; BANDS - 1],
},
state: [Channel::default(); 2],
windows: std::array::from_fn(|_| Window::new(period)),
levels: [SILENT; BANDS],
gains: [0.; BANDS],
attack: [1.; BANDS],
release: [1.; BANDS],
smooth: coefficient(rate, 10.),
depth: 0.,
drive: 0.,
band_gains: [1.; BANDS],
output: 1.,
bypass: 0.,
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
e.retune();
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
if s.time != self.settings.time || self.fresh {
let scale = s.time * 0.01;
for b in 0..BANDS {
self.attack[b] = coefficient(self.rate, ATTACK_MS[b] * scale);
self.release[b] = coefficient(self.rate, RELEASE_MS[b] * scale);
}
}
if s.tops() != self.settings.tops() {
self.countdown = 0;
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.depth = s.depth * 0.01;
self.drive = s.drive;
self.band_gains = s.gains.map(db_to_gain);
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.state = [Channel::default(); 2];
for w in &mut self.windows {
w.reset();
}
self.levels = [SILENT; BANDS];
self.gains = [0.; BANDS];
self.countdown = 0;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Fundamental the edges follow: the tracked note, or the fallback before the first one.
fn root(&self) -> f32 {
if self.hz > 0. {
self.hz
} else {
FALLBACK_HZ
}
}
// Bands, splits and channels index several parallel arrays at once.
#[allow(clippy::needless_range_loop)]
fn retune(&mut self) {
let root = self.root();
let tops = self.settings.tops();
for s in 0..BANDS - 1 {
// Geometric middle: the harmonics on both sides of an edge lose the same amount.
let k = tops[s] as f32;
let hz = ((k * (k + 1.)).sqrt() * root).clamp(10., 0.45 * self.rate);
self.edges[s] = hz;
self.coefs.low[s] = SvfCoef::lowpass(self.rate, hz, Q);
self.coefs.high[s] = SvfCoef::highpass(self.rate, hz, Q);
self.coefs.all[s] = SvfCoef::allpass(self.rate, hz, Q);
}
let period = (self.rate / root).round() as usize;
for w in &mut self.windows {
w.set_len(period);
}
}
// Bands, splits and channels index several parallel arrays at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
let (hz, jump) = self.pitch.step(self.hz_coef);
self.hz = hz;
if jump || self.countdown == 0 {
self.retune();
self.countdown = RETUNE;
}
self.countdown -= 1;
let s = self.settings;
approach(&mut self.depth, s.depth * 0.01, self.smooth);
approach(&mut self.drive, s.drive, self.smooth);
for b in 0..BANDS {
approach(&mut self.band_gains[b], db_to_gain(s.gains[b]), self.smooth);
}
approach(&mut self.output, db_to_gain(s.output), self.smooth);
approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
let mut bands = [[0.; BANDS]; 2];
for c in 0..self.channels {
bands[c] = self.state[c].split(&self.coefs, y[c]);
}
let (up, down) = (s.upward * 0.01, s.downward * 0.01);
let mut wet = [0.; 2];
for b in 0..BANDS {
let power = if self.channels == 2 {
0.5 * (bands[0][b] * bands[0][b] + bands[1][b] * bands[1][b])
} else {
bands[0][b] * bands[0][b]
};
let level = 10. * (self.windows[b].push(power) + 1e-12).log10() + self.drive;
let coef = if level > self.levels[b] {
self.attack[b]
} else {
self.release[b]
};
self.levels[b] += (level - self.levels[b]) * coef;
self.gains[b] = compressor_gain(self.levels[b], up, down);
let g = 1. + self.depth * (db_to_gain(self.gains[b]) * self.band_gains[b] - 1.);
for c in 0..self.channels {
wet[c] += bands[c][b] * g;
}
}
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = wet[c] * self.output * (1. - self.bypass) + y[c] * self.bypass;
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
edges: self.edges,
levels: self.levels,
gains: self.gains,
latency: self.latency as u32,
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if self.tracker.sounding() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
use serde::{Deserialize, Serialize};
/// Bands: the fundamental, low harmonics, mid harmonics and the top.
pub const BANDS: usize = 4;
pub const GAIN_MIN: f32 = -24.;
pub const GAIN_MAX: f32 = 24.;
/// Highest harmonic a band edge can be set to.
pub const TOP_MAX: u32 = 48;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Highest harmonic in the bottom band. 1 keeps the fundamental on its own.
pub low: u32,
/// Highest harmonic in the second band.
pub mid: u32,
/// Highest harmonic in the third band; everything above is the top band.
pub high: u32,
/// Share of the compressed signal, per band, against the dry band.
pub depth: f32,
/// Scales every attack and release time.
pub time: f32,
/// Amount of upward compression: quiet bands are lifted.
pub upward: f32,
/// Amount of downward compression: loud bands are pushed down.
pub downward: f32,
/// Gain on the level the compressors see, not on the audio: moves the band levels against
/// the thresholds. More drive means more squash; at Depth 0 it changes nothing.
pub drive: f32,
/// Gain of each band after its compressor, bottom band first.
pub gains: [f32; BANDS],
/// Retune time when the pitch slides or bends.
pub smooth: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone. Off by default: the level window is one
/// period of the pitch in use, and a detuned note then gets a window that does not fit.
pub snap: bool,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
low: 1,
mid: 4,
high: 16,
depth: 50.,
time: 100.,
upward: 100.,
downward: 100.,
drive: 0.,
gains: [0.; BANDS],
smooth: 10.,
gate: -42.,
snap: false,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.depth,
self.time,
self.upward,
self.downward,
self.drive,
self.smooth,
self.gate,
self.output,
]
.iter()
.chain(self.gains.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (1..=8).contains(&self.low)
&& (2..=16).contains(&self.mid)
&& (3..=TOP_MAX).contains(&self.high)
&& self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
&& (0.0..=100.).contains(&self.depth)
&& (10.0..=1000.).contains(&self.time)
&& (0.0..=100.).contains(&self.upward)
&& (0.0..=100.).contains(&self.downward)
&& (-24.0..=24.).contains(&self.drive)
&& (0.0..=100.).contains(&self.smooth)
&& (-60.0..=-20.).contains(&self.gate)
&& (-24.0..=24.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
/// Highest harmonic of the bottom three bands, in rising order. Host automation can set
/// the edges in any order, so a lower edge pushes the next one up instead of failing.
pub fn tops(&self) -> [u32; BANDS - 1] {
let low = self.low.clamp(1, 8);
let mid = self.mid.max(low + 1);
let high = self.high.max(mid + 1);
[low, mid, high]
}
/// Text for the harmonics of `band`, like "1", "2–4" or "17+".
pub fn band_label(&self, band: usize) -> String {
let t = self.tops();
let first = if band == 0 { 1 } else { t[band - 1] + 1 };
match band {
3 => format!("{first}+"),
_ if first == t[band] => format!("{first}"),
_ => format!("{first}–{}", t[band]),
}
}
}
+309
View File
@@ -0,0 +1,309 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use planish_core::{compressor_gain, Engine, Settings, Source, BANDS};
use std::f32::consts::{PI, TAU};
const RATE: f32 = 48000.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
let l = e.latency_samples() as usize;
(e, l)
}
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
let mut s = Saw::new(RATE, hz);
(0..(seconds * RATE) as usize)
.map(|_| s.sample() * amp)
.collect()
}
/// Sum of sines `(harmonic, amplitude)` on `hz`, with the phase in f64 so long signals stay
/// clean.
fn tones(hz: f32, seconds: f32, parts: &[(usize, f32)]) -> Vec<f32> {
(0..(seconds * RATE) as usize)
.map(|i| {
let t = i as f64 / RATE as f64;
parts
.iter()
.map(|&(k, a)| {
let phase = (TAU as f64 * hz as f64 * k as f64 * t) % (TAU as f64);
a * (phase as f32).sin()
})
.sum()
})
.collect()
}
fn sine(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
tones(hz, seconds, &[(1, amp)])
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
}
/// Harmonic levels of the last complete window of `x`.
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
let mut m = HarmonicMeter::new(RATE, 85.);
for &v in x {
m.push(v, hz);
}
*m.levels()
}
fn rms_db(x: &[f32]) -> f32 {
let p: f32 = x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32;
10. * (p + 1e-20).log10()
}
fn uncompressed() -> Settings {
Settings {
depth: 0.,
..Settings::default()
}
}
#[test]
fn bands_sum_flat_without_compression() {
for hz in [35., 80., 250., 1000., 4000., 12000.] {
let x = sine(hz, 0.6, 0.5);
let (mut e, l) = engine(uncompressed());
let y = render(&mut e, &x);
let tail = y.len() / 2;
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
assert!(d.abs() < 0.05, "{hz} Hz: {d:+.3} dB");
}
}
#[test]
fn band_edges_sit_between_the_same_harmonics_on_every_note() {
// A saw with amplitude a has harmonic k at 2a/(πk). Band 1 holds harmonic 1 minus what
// its slope gives away; the split of the series between the bands is the same on every
// note.
let a = 0.5;
let fundamental = 10. * ((2. * a / PI).powi(2) / 2.).log10();
let mut first: Option<[f32; BANDS]> = None;
for hz in [43.65, 65.41, 130.81] {
let (mut e, _) = engine(uncompressed());
render(&mut e, &saw(hz, 1., a));
let f = e.take_analyzer();
assert!((f.hz - hz).abs() < 0.5, "{hz}: tracked {}", f.hz);
assert!((f.edges[0] - 2f32.sqrt() * hz).abs() < 1., "{:?}", f.edges);
assert!((f.edges[1] - 20f32.sqrt() * hz).abs() < 2., "{:?}", f.edges);
assert!(
(f.levels[0] - fundamental).abs() < 2.5,
"{hz}: {:?}",
f.levels
);
let levels = *first.get_or_insert(f.levels);
// The top band also holds the saw's own roll-off near Nyquist, which is not harmonic.
for (b, tolerance) in [0.4, 0.4, 0.4, 1.].into_iter().enumerate() {
let d = f.levels[b] - levels[b];
assert!(d.abs() < tolerance, "{hz}: {:?} vs {levels:?}", f.levels);
}
}
}
#[test]
fn the_same_setting_changes_the_same_harmonics_on_every_note() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let notes = [43.65, 65.41, 98.];
let mut changes = Vec::new();
for hz in notes {
let x = saw(hz, 1.5, 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
changes.push((0..12).map(|k| b[k] - a[k]).collect::<Vec<f32>>());
}
// Compression must actually happen for the comparison to mean anything.
assert!(changes[0][0] < -6., "{:?}", changes[0]);
for (n, c) in changes.iter().enumerate().skip(1) {
for k in 0..12 {
let d = c[k] - changes[0][k];
assert!(
d.abs() < 1.5,
"note {n} harmonic {}: {c:?} vs {:?}",
k + 1,
changes[0]
);
}
}
}
#[test]
fn upward_lifts_a_quiet_band_and_downward_pushes_a_loud_one() {
// Loud fundamental in band 1, a quiet 8th harmonic in band 3.
let hz = 55.;
let x = tones(hz, 1.5, &[(1, 0.7), (8, 0.003)]);
let a = harmonics(&x, hz);
let run = |upward: f32, downward: f32| {
let s = Settings {
depth: 100.,
upward,
downward,
..Settings::default()
};
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let b = harmonics(&y[l..], hz);
[b[0] - a[0], b[7] - a[7]]
};
let [body, top] = run(100., 0.);
assert!(body.abs() < 0.5 && top > 5., "upward: {body:+.2} {top:+.2}");
let [body, top] = run(0., 100.);
assert!(
body < -10. && top.abs() < 1.,
"downward: {body:+.2} {top:+.2}"
);
}
/// Level in dB of harmonic `k` of `hz` in `x`, over whole periods (Goertzel).
fn partial(x: &[f32], hz: f32, k: usize) -> f32 {
let w = TAU as f64 * hz as f64 * k as f64 / RATE as f64;
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
re += v as f64 * (w * i as f64).cos();
im += v as f64 * (w * i as f64).sin();
}
let amp = 2. * (re * re + im * im).sqrt() / x.len() as f64;
20. * (amp + 1e-12).log10() as f32
}
#[test]
fn heavy_compression_does_not_ripple_at_the_fundamental() {
// A per-period level is steady on a steady note, so even fast, hard compression adds no
// harmonics to a sine. 40 Hz has a period of exactly 1200 samples; Snap is off so the
// window is the measured period, not the nearest semitone's.
let hz = 40.;
let x = sine(hz, 1.5, 0.5);
let distortion = |depth: f32| {
let s = Settings {
depth,
drive: 12.,
time: 10.,
snap: false,
..Settings::default()
};
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let tail = &y[y.len() - 24000..];
let fundamental = partial(tail, hz, 1);
(2..6)
.map(|k| partial(tail, hz, k) - fundamental)
.fold(f32::MIN, f32::max)
};
let (dry, squashed) = (distortion(0.), distortion(100.));
assert!(squashed < -80., "{squashed:.1} dB (dry {dry:.1} dB)");
}
#[test]
fn silence_and_noise_floors_are_not_lifted() {
assert_eq!(compressor_gain(-120., 1., 1.), 0.);
assert!(compressor_gain(-60., 1., 1.) > 10.);
let s = Settings {
depth: 100.,
..Settings::default()
};
let x = saw(55., 1., 2e-5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let tail = y.len() / 2;
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
assert!(d < 2., "{d:+.2} dB");
}
#[test]
fn bypass_passes_the_delayed_input_exactly() {
let s = Settings {
depth: 100.,
bypass: true,
..Settings::default()
};
let x = saw(55., 0.5, 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i], x[i - l]);
}
}
#[test]
fn note_changes_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let mut x = saw(43.65, 0.5, 0.5);
x.extend(saw(58.27, 0.5, 0.5));
x.extend(saw(87.31, 0.5, 0.5));
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let step = y
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
let in_step = x
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
assert!(y.iter().all(|v| v.is_finite()));
assert!(step < 2. * in_step, "{step} vs {in_step}");
}
#[test]
fn midi_source_moves_the_edges_at_the_delayed_note_time() {
let s = Settings {
source: Source::Midi,
..uncompressed()
};
let (mut e, l) = engine(s);
// 43.65 Hz is MIDI 29.
e.note_on(29);
render(&mut e, &saw(43.65, l as f32 / RATE - 0.002, 0.5));
assert_eq!(e.take_analyzer().hz, 0.);
render(&mut e, &saw(43.65, 0.01, 0.5));
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
assert!(
(f.edges[0] - 2f32.sqrt() * 43.65).abs() < 0.2,
"{:?}",
f.edges
);
}
#[test]
fn edges_in_the_wrong_order_are_pushed_up() {
let s = Settings {
low: 4,
mid: 3,
high: 3,
..Settings::default()
};
assert!(s.validate().is_ok());
assert_eq!(s.tops(), [4, 5, 6]);
assert_eq!(s.band_label(0), "1–4");
assert_eq!(s.band_label(1), "5");
assert_eq!(s.band_label(3), "7+");
let d = Settings::default();
let labels: Vec<String> = (0..BANDS).map(|b| d.band_label(b)).collect();
assert_eq!(labels, ["1", "2–4", "5–16", "17+"]);
}
#[test]
fn settings_are_validated_and_serialise() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
let mut bad = s;
bad.gains[2] = 40.;
assert!(bad.validate().is_err());
bad = s;
bad.low = 0;
assert!(bad.validate().is_err());
bad = s;
bad.time = f32::NAN;
assert!(bad.validate().is_err());
let mut e = Engine::new(RATE, 2).unwrap();
assert!(e.set_settings(bad).is_err());
assert_eq!(e.settings(), s);
}
+71
View File
@@ -0,0 +1,71 @@
use planish_core::{Engine, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.gains[(i / 4800) % 4] = -6.;
settings.low = 1 + ((i / 4800) % 3) as u32;
settings.time = 10. + ((i / 4800) % 5) as f32 * 100.;
settings.depth = 100.;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "planish"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
planish-core = { path = "../core" }
planish-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+185
View File
@@ -0,0 +1,185 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{PlanishParams, FLOATS};
use planish_core::{AnalyzerFrame, Engine};
use std::sync::Arc;
struct EditorData {
ui: planish_ui::State,
gestures: [bool; FLOATS],
}
pub struct Planish {
params: Arc<PlanishParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Planish {
fn default() -> Self {
Self {
params: Arc::new(PlanishParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Planish {
const NAME: &'static str = "Planish";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: planish_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| planish_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("planish-editor")
.min_size(egui::vec2(
planish_ui::WIDTH as f32 - 80.,
planish_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
planish_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Band meters follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and compressor release must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Planish {
const CLAP_ID: &'static str = "io.narl.bass-foundry.planish";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Upward and downward compression in bands set by harmonic number, the same on every note",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Compressor,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Planish);
+192
View File
@@ -0,0 +1,192 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use planish_core::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
fn edge(name: &str, v: u32, min: i32, max: i32) -> IntParam {
IntParam::new(name, v as i32, IntRange::Linear { min, max })
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 12;
#[derive(Params)]
pub struct PlanishParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "depth"]
pub depth: FloatParam,
#[id = "time"]
pub time: FloatParam,
#[id = "upward"]
pub upward: FloatParam,
#[id = "downward"]
pub downward: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "gain-1"]
pub gain_1: FloatParam,
#[id = "gain-2"]
pub gain_2: FloatParam,
#[id = "gain-3"]
pub gain_3: FloatParam,
#[id = "gain-4"]
pub gain_4: FloatParam,
#[id = "smooth"]
pub smooth: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "low"]
pub low: IntParam,
#[id = "mid"]
pub mid: IntParam,
#[id = "high"]
pub high: IntParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for PlanishParams {
fn default() -> Self {
let d = Settings::default();
let gain = |n: usize| linear(&format!("Band {n} gain"), 0., GAIN_MIN, GAIN_MAX, " dB");
Self {
editor: EguiState::from_size(planish_ui::WIDTH, planish_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
depth: linear("Depth", d.depth, 0., 100., " %"),
time: linear("Time", d.time, 10., 1000., " %"),
upward: linear("Upward", d.upward, 0., 100., " %"),
downward: linear("Downward", d.downward, 0., 100., " %"),
drive: linear("Drive", d.drive, -24., 24., " dB"),
gain_1: gain(1),
gain_2: gain(2),
gain_3: gain(3),
gain_4: gain(4),
smooth: linear("Smooth", d.smooth, 0., 100., " ms"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
output: linear("Output", d.output, -24., 24., " dB"),
low: edge("Low edge", d.low, 1, 8),
mid: edge("Mid edge", d.mid, 2, 16),
high: edge("High edge", d.high, 3, TOP_MAX as i32),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl PlanishParams {
pub fn settings(&self) -> Settings {
let gains = [&self.gain_1, &self.gain_2, &self.gain_3, &self.gain_4];
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
low: self.low.value() as u32,
mid: self.mid.value() as u32,
high: self.high.value() as u32,
depth: self.depth.value(),
time: self.time.value(),
upward: self.upward.value(),
downward: self.downward.value(),
drive: self.drive.value(),
gains: std::array::from_fn::<f32, BANDS, _>(|b| gains[b].value()),
smooth: self.smooth.value(),
gate: self.gate.value(),
snap: self.snap.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.depth,
&self.time,
&self.upward,
&self.downward,
&self.drive,
&self.gain_1,
&self.gain_2,
&self.gain_3,
&self.gain_4,
&self.smooth,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[
s.depth, s.time, s.upward, s.downward, s.drive, s.gains[0], s.gains[1], s.gains[2],
s.gains[3], s.smooth, s.gate, s.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches and edges are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
for (p, a, b) in [
(&self.low, old.low, new.low),
(&self.mid, old.mid, new.mid),
(&self.high, old.high, new.high),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b as i32);
setter.end_set_parameter(p);
}
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &planish::clap_entry;
assert!(entry.init.unwrap()(c"planish.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Planish");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.planish"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
// Depth 0 makes the processed signal a pure crossover sum: the tone must pass flat.
envelope.params.insert("depth".into(), ParamValue::F32(0.));
envelope
.params
.insert("gain-2".into(), ParamValue::F32(-6.));
envelope.params.insert("low".into(), ParamValue::I32(2));
envelope.params.insert("high".into(), ParamValue::I32(24));
envelope
.params
.insert("snap".into(), ParamValue::Bool(true));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone through the crossovers, at depth 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "planish-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
planish-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+507
View File
@@ -0,0 +1,507 @@
//! Planish editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use planish_core::{
AnalyzerFrame, Settings, Source, BANDS, DOWN_THRESHOLD, FALLBACK_HZ, FLOOR, GAIN_MAX, GAIN_MIN,
TOP_MAX, UP_MAX, UP_THRESHOLD,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// Band edge being dragged on the harmonic ruler.
dragging: Option<usize>,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
dragging: None,
}
}
}
const DEFAULT_HELP: &str =
"Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note, wherever they are in Hz.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
// Levels fall smoothly so bars stay readable between frames.
for b in 0..BANDS {
self.analyzer.levels[b] = frame.levels[b].max(old.levels[b] - 3.);
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
/// One colour per band, bottom band first.
const BAND_COLORS: [Color32; BANDS] = [
Color32::from_rgb(255, 1, 96),
Color32::from_rgb(226, 120, 64),
Color32::from_rgb(126, 167, 216),
Color32::from_rgb(184, 192, 204),
];
/// Harmonics shown on the ruler: 1 to 64 on a log scale.
const RULER_MAX: f32 = 64.;
const LEVEL_TOP: f32 = 0.;
const TOP: f32 = 330.;
fn ruler_x(rect: Rect, harmonic: f32) -> f32 {
rect.left() + rect.width() * (harmonic.max(1.).log2() / RULER_MAX.log2())
}
fn x_ruler(rect: Rect, x: f32) -> f32 {
2f32.powf((x - rect.left()) / rect.width() * RULER_MAX.log2())
}
fn level_y(rect: Rect, db: f32) -> f32 {
let t = (db - FLOOR) / (LEVEL_TOP - FLOOR);
rect.bottom() - rect.height() * t.clamp(0., 1.)
}
fn gain_y(rect: Rect, db: f32) -> f32 {
let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN);
rect.bottom() - rect.height() * t.clamp(0., 1.)
}
fn edge_mut(settings: &mut Settings, edge: usize) -> &mut u32 {
match edge {
0 => &mut settings.low,
1 => &mut settings.mid,
_ => &mut settings.high,
}
}
fn edge_range(edge: usize) -> (u32, u32) {
match edge {
0 => (1, 8),
1 => (2, 16),
_ => (3, TOP_MAX),
}
}
/// Harmonic ruler with draggable band edges, then one column per band with its level,
/// thresholds and compressor gain.
fn band_display(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let size = Vec2::new(ui.available_width(), height);
let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
let ruler = Rect::from_min_max(
outer.left_top() + Vec2::new(16., 26.),
Pos2::new(outer.right() - 16., outer.top() + 62.),
);
let columns = Rect::from_min_max(
Pos2::new(outer.left() + 40., ruler.bottom() + 34.),
Pos2::new(outer.right() - 40., outer.bottom() - 34.),
);
let tops = settings.tops();
let f = state.analyzer;
let root = if f.hz > 0. { f.hz } else { FALLBACK_HZ };
// Ruler: band spans, harmonic ticks, and edges between harmonics.
let edge_at = |k: u32| ((k * (k + 1)) as f32).sqrt();
let mut from = 1f32;
for b in 0..BANDS {
let to = if b < BANDS - 1 {
edge_at(tops[b])
} else {
RULER_MAX
};
let span = Rect::from_min_max(
Pos2::new(ruler_x(ruler, from), ruler.top() + 14.),
Pos2::new(ruler_x(ruler, to), ruler.bottom()),
);
p.rect_filled(span, 1., BAND_COLORS[b].gamma_multiply(0.22));
p.text(
span.center_top() + Vec2::new(0., -2.),
egui::Align2::CENTER_BOTTOM,
format!("BAND {} · {}", b + 1, settings.band_label(b)),
FontId::proportional(10.),
BAND_COLORS[b],
);
from = to;
}
for k in 1..=RULER_MAX as u32 {
let x = ruler_x(ruler, k as f32);
let major = k.is_power_of_two() || k <= 8;
let tick = if major { 10. } else { 5. };
p.line_segment(
[
Pos2::new(x, ruler.bottom() - tick),
Pos2::new(x, ruler.bottom()),
],
Stroke::new(1.0_f32, if major { MUTED } else { DIM }),
);
if major {
p.text(
Pos2::new(x, ruler.bottom() + 3.),
egui::Align2::CENTER_TOP,
format!("{k}"),
FontId::proportional(10.),
MUTED,
);
}
}
let nearest = |x: f32| {
(0..BANDS - 1)
.min_by(|&a, &b| {
let da = (ruler_x(ruler, edge_at(tops[a])) - x).abs();
let db = (ruler_x(ruler, edge_at(tops[b])) - x).abs();
da.total_cmp(&db)
})
.unwrap_or(0)
};
let hover_edge = response
.hover_pos()
.filter(|p| p.y < ruler.bottom() + 16.)
.map(|p| nearest(p.x));
if response.drag_started() {
state.dragging = response
.interact_pointer_pos()
.filter(|p| p.y < ruler.bottom() + 16.)
.map(|p| nearest(p.x));
}
if let (Some(edge), Some(pos)) = (state.dragging, response.interact_pointer_pos()) {
let (lo, hi) = edge_range(edge);
// The edge above harmonic k sits at √(k(k+1)), so k is the harmonic below the pointer.
let k = (x_ruler(ruler, pos.x) - 0.5)
.round()
.clamp(lo as f32, hi as f32) as u32;
*edge_mut(settings, edge) = k;
// Keep the order visible while dragging: the other edges make room.
let t = settings.tops();
settings.low = t[0];
settings.mid = t[1].min(16);
settings.high = t[2];
}
if !response.dragged() {
state.dragging = None;
}
for (e, &top) in settings.tops().iter().enumerate() {
let x = ruler_x(ruler, edge_at(top));
let active = state.dragging == Some(e) || hover_edge == Some(e);
p.line_segment(
[
Pos2::new(x, ruler.top() + 10.),
Pos2::new(x, ruler.bottom() + 2.),
],
Stroke::new(
if active { 2.5_f32 } else { 1.5 },
if active { TEXT } else { STEEL },
),
);
}
// Columns: level (steel) against the thresholds, gain (accent cut, blue lift).
for db in [0., -12., -24., -36., -48., -60., -72.] {
let y = level_y(columns, db);
p.line_segment(
[Pos2::new(columns.left(), y), Pos2::new(columns.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 8., y),
egui::Align2::LEFT_CENTER,
format!("{db:.0}"),
FontId::proportional(10.),
DIM,
);
}
// Gain scale on the right, for the bars.
for db in [24., 12., 0., -12., -24.] {
p.text(
Pos2::new(outer.right() - 8., gain_y(columns, db)),
egui::Align2::RIGHT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:+.0}")
},
FontId::proportional(10.),
if db == 0. { MUTED } else { DIM },
);
}
let col = columns.width() / BANDS as f32;
let depth = settings.depth * 0.01;
for b in 0..BANDS {
let x0 = columns.left() + col * b as f32;
let level = Rect::from_min_max(
Pos2::new(x0 + col * 0.14, level_y(columns, f.levels[b])),
Pos2::new(x0 + col * 0.44, columns.bottom()),
);
if level.height() > 0.5 {
p.rect_filled(level, 1., STEEL.gamma_multiply(0.8));
}
for (db, amount, color) in [
(DOWN_THRESHOLD, settings.downward, ACCENT),
(UP_THRESHOLD, settings.upward, FOCUS),
] {
if amount > 0. {
let y = level_y(columns, db);
p.line_segment(
[Pos2::new(x0 + col * 0.08, y), Pos2::new(x0 + col * 0.5, y)],
Stroke::new(1.0_f32, color.gamma_multiply(0.8)),
);
}
}
// The same blend as the engine: Depth mixes the band with its compressed copy.
let wet = 10f32.powf((f.gains[b] + settings.gains[b]) / 20.);
let gain = 20. * (1. + depth * (wet - 1.)).max(1e-6).log10();
let zero = gain_y(columns, 0.);
let y = gain_y(columns, gain);
let bar = Rect::from_min_max(
Pos2::new(x0 + col * 0.56, zero.min(y)),
Pos2::new(x0 + col * 0.86, zero.max(y)),
);
p.line_segment(
[
Pos2::new(x0 + col * 0.52, zero),
Pos2::new(x0 + col * 0.9, zero),
],
Stroke::new(1.0_f32, EDGE),
);
if bar.height() >= 1. {
p.rect_filled(bar, 1., if gain < 0. { ACCENT } else { FOCUS });
}
let hz = |k: f32| k * root;
let low = if b == 0 { 1. } else { edge_at(tops[b - 1]) };
let range = if b < BANDS - 1 {
format!("{:.0}–{:.0} Hz", hz(low), hz(edge_at(tops[b])))
} else {
format!("{:.0} Hz +", hz(low))
};
p.text(
Pos2::new(x0 + col * 0.5, columns.bottom() + 6.),
egui::Align2::CENTER_TOP,
format!("{} · {gain:+.1} dB", settings.band_label(b)),
FontId::proportional(11.),
BAND_COLORS[b],
);
p.text(
Pos2::new(x0 + col * 0.5, columns.bottom() + 20.),
egui::Align2::CENTER_TOP,
range,
FontId::proportional(10.),
MUTED,
);
}
p.text(
Pos2::new(outer.right() - 8., ruler.bottom() + 18.),
egui::Align2::RIGHT_TOP,
"LEVEL dB · GAIN dB",
FontId::proportional(11.),
MUTED,
);
p.text(
outer.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"HARMONIC",
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
outer,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
if let Some(e) = state.dragging.or(hover_edge) {
let k = tops[e];
state.help = format!(
"Edge between harmonics {k} and {}: {:.0} Hz on this note. Drag to move it.",
k + 1,
edge_at(k) * root,
);
} else if response.hovered() {
state.help = format!(
"Level plus Drive (steel) against the upward (blue) and downward (pink) thresholds; bar: band gain. Lift is at most {UP_MAX:.0} dB."
);
}
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "PLANISH", "HARMONIC OTT", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
band_display(ui, settings, state, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Label, help text and band edges (highest harmonic of bands 1–3).
type Split = (&'static str, &'static str, [u32; 3]);
fn note_panel(
ui: &mut egui::Ui,
settings: &mut Settings,
state: &mut State,
frame: &AnalyzerFrame,
) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => (
"—".into(),
format!("no note yet · edges on {FALLBACK_HZ:.0} Hz"),
),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(6.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI",
}
))
.size(12.)
.color(MUTED),
);
ui.add_space(14.);
caption(ui, "SPLITS");
let splits: [Split; 4] = [
(
"CLASSIC",
"Fundamental alone, body 2–4, mids 5–16, top 17 and up.",
[1, 4, 16],
),
(
"SUB + BODY",
"Harmonics 1–2 together, so the octave stays locked to the sub.",
[2, 6, 24],
),
(
"TIGHT",
"Narrow low bands for close control of the first harmonics.",
[1, 2, 8],
),
(
"WIDE TOP",
"One big mid band, a top that starts high: fizz only.",
[1, 3, 32],
),
];
ui.horizontal_wrapped(|ui| {
for (label, help, [low, mid, high]) in splits {
let on = settings.tops() == [low, mid, high];
let r = ui.selectable_label(on, RichText::new(label).size(12.));
if r.clicked() {
settings.low = low;
settings.mid = mid;
settings.high = high;
}
hint(&r, state, help);
}
});
ui.add_space(10.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the level window on the real period, which is steadier on detuned patches.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(12, |c| {
knob(&mut c[0], state, "Drive", &mut settings.drive, -24. ..=24., d.drive, " dB", 64.,
"Pushes the band levels against the thresholds: more drive, more squash. It does not change the level by itself.");
knob(&mut c[1], state, "Depth", &mut settings.depth, 0. ..=100., d.depth, " %", 64.,
"How much of the compression is applied. 0 % is a clean crossover sum.");
knob(&mut c[2], state, "Upward", &mut settings.upward, 0. ..=100., d.upward, " %", 64.,
"Lift for bands below the upward threshold: brings up quiet harmonics.");
knob(&mut c[3], state, "Downward", &mut settings.downward, 0. ..=100., d.downward, " %", 64.,
"Cut for bands above the downward threshold: holds loud harmonics down.");
knob(&mut c[4], state, "Time", &mut settings.time, 10. ..=1000., d.time, " %", 64.,
"Scales every attack and release. Lower bands are slower than higher ones.");
for b in 0..BANDS {
let name = format!("Band {}", b + 1);
let help = format!(
"Gain of band {} (harmonics {}) after its compressor, scaled by Depth.",
b + 1,
settings.band_label(b)
);
knob(&mut c[5 + b], state, &name, &mut settings.gains[b], GAIN_MIN..=GAIN_MAX,
d.gains[b], " dB", 54., &help);
}
knob(&mut c[9], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54.,
"Retune time for slides and bends. New notes always retune at once.");
knob(&mut c[10], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54.,
"Input level that counts as a note. Raise it if tails retrigger.");
knob(&mut c[11], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54.,
"Output trim.");
});
}
+21
View File
@@ -164,6 +164,8 @@ dependencies = [
"lathe-ui",
"patina-core",
"patina-ui",
"planish-core",
"planish-ui",
"rivet-app",
"rivet-core",
"rivet-midi",
@@ -416,6 +418,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
]
@@ -429,6 +432,24 @@ dependencies = [
"patina-core",
]
[[package]]
name = "planish-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
]
[[package]]
name = "planish-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"planish-core",
]
[[package]]
name = "png"
version = "0.18.1"
+2
View File
@@ -25,5 +25,7 @@ temper-core = { path = "../../plugins/temper/core" }
temper-ui = { path = "../../plugins/temper/ui" }
knurl-core = { path = "../../plugins/knurl/core" }
knurl-ui = { path = "../../plugins/knurl/ui" }
planish-core = { path = "../../plugins/planish/core" }
planish-ui = { path = "../../plugins/planish/ui" }
weld-core = { path = "../../plugins/weld/core" }
weld-ui = { path = "../../plugins/weld/ui" }
+34 -5
View File
@@ -11,11 +11,13 @@ fn main() {
.map(|a| a[1].as_str())
.unwrap_or("rivet");
if ![
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl",
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish",
]
.contains(&plugin)
{
eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld or knurl");
eprintln!(
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl or planish"
);
std::process::exit(2);
}
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
@@ -26,7 +28,10 @@ fn main() {
let temper = plugin == "temper";
let weld = plugin == "weld";
let knurl = plugin == "knurl";
let (width, height) = if knurl {
let planish = plugin == "planish";
let (width, height) = if planish {
(planish_ui::WIDTH, planish_ui::HEIGHT)
} else if knurl {
(knurl_ui::WIDTH, knurl_ui::HEIGHT)
} else if weld {
(weld_ui::WIDTH, weld_ui::HEIGHT)
@@ -165,6 +170,26 @@ fn main() {
}
knurl_state.accept_frame(engine.take_analyzer());
}
let mut planish_settings = planish_core::Settings {
depth: 80.,
gains: [0., 2., 3., -2.],
..Default::default()
};
let mut planish_state = planish_ui::State::default();
if planish {
// A distorted F1 saw bass: loud body, thin top, so both directions work.
let mut engine = planish_core::Engine::new(48000., 2).unwrap();
engine.set_settings(planish_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
let x = (saw.sample() * 1.6).tanh() * 0.4;
engine.process_sample([x, x]);
if i % 1600 == 0 {
planish_state.accept_frame(engine.take_analyzer());
}
}
planish_state.accept_frame(engine.take_analyzer());
}
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
@@ -294,7 +319,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if knurl {
if planish {
planish_ui::style(&ctx);
} else if knurl {
knurl_ui::style(&ctx);
} else if weld {
weld_ui::style(&ctx);
@@ -331,7 +358,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if knurl {
if planish {
planish_ui::show(ui, &mut planish_settings, &mut planish_state);
} else if knurl {
knurl_ui::show(ui, &mut knurl_settings, &mut knurl_state);
} else if weld {
weld_ui::show(ui, &mut weld_settings, &mut weld_state);
+15
View File
@@ -169,6 +169,21 @@ const COPY: Record<string, Copy> = {
latency: '≈ 71 ms',
tracksNotes: true,
},
planish: {
role: 'Harmonic OTT',
metal: 'Planishing hammers a metal surface smooth and even.',
problem:
'OTT splits its bands at fixed frequencies: harmonic 2 of F1 lands in the low band, harmonic 2 of C2 in the mids. The same setting squashes different harmonics on every note.',
points: [
'Upward and downward compression in four bands.',
'Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note.',
'Level measured over one period of the note, so heavy settings do not ripple.',
'Depth, Drive, Time and a gain per band. Pitch from the audio, or from MIDI.',
],
loadOn: 'Bass, after the distortion',
latency: '≈ 67 ms',
tracksNotes: true,
},
};
export interface Plugin extends Copy {