Compare commits

...
10 Commits
Author SHA1 Message Date
nvrl d5bdd3e2c7 fix(website): spell plugin counts above ten in the hero
Check and Windows preview / check (anvil) (push) Successful in 50s
Check and Windows preview / check (ballast) (push) Successful in 48s
Check and Windows preview / check (braze) (push) Successful in 49s
Check and Windows preview / check (crucible) (push) Successful in 50s
Check and Windows preview / check (forge) (push) Successful in 55s
Check and Windows preview / check (knurl) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 44s
Check and Windows preview / check (patina) (push) Successful in 1m17s
Check and Windows preview / check (planish) (push) Successful in 44s
Check and Windows preview / check (quench) (push) Successful in 1m32s
Check and Windows preview / check (rivet) (push) Successful in 1m40s
Check and Windows preview / check (temper) (push) Successful in 1m38s
Check and Windows preview / check (weld) (push) Successful in 3m22s
Check and Windows preview / previews (push) Successful in 2m3s
Check and Windows preview / windows (push) Successful in 2m25s
PLUGINS grew to 13, past the end of the number-word list, so the count fell
back to a number and toUpperCase failed during the static build.
2026-11-06 17:51:19 +01:00
nvrl 6295c516f2 chore: release Forge 0.1.0
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (ballast) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (forge) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (quench) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m18s
Independent plugin release / publish (push) Successful in 10s
- Forge 0.1.0: first release.
- Mark Forge released in docs/ideas.md and add it to the release table.
2026-11-06 17:48:49 +01:00
nvrl 109763c768 chore: release Patina 0.5.0
Check and Windows preview / check (anvil) (push) Successful in 2m22s
Check and Windows preview / check (ballast) (push) Successful in 1m39s
Check and Windows preview / check (braze) (push) Successful in 1m35s
Check and Windows preview / check (crucible) (push) Successful in 1m51s
Check and Windows preview / check (forge) (push) Successful in 2m33s
Check and Windows preview / check (knurl) (push) Successful in 1m35s
Check and Windows preview / check (lathe) (push) Successful in 1m41s
Check and Windows preview / check (patina) (push) Successful in 3m37s
Check and Windows preview / check (planish) (push) Successful in 1m49s
Check and Windows preview / check (quench) (push) Canceled after 1m13s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Independent plugin release / plan (push) Successful in 9s
Independent plugin release / build (push) Successful in 3m53s
Independent plugin release / publish (push) Successful in 10s
- Threshold, Ratio and Soft/Hard knee: each band cuts like a small compressor.
- Mark the feedback item released in docs/ideas.md and update the release table.
2026-11-06 17:36:31 +01:00
nvrl 9ca4e4f9dd feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
Tester feedback asked for a threshold and a ratio. Each band now cuts like a
small compressor: Threshold (dB above the local floor) replaces Selectivity,
Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a
Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut
out. Mix and Output move to the slider panel so the detection panel fits.
common-ui gains rotary_text for knobs with custom value text.
2026-11-06 17:29:54 +01:00
nvrl 7b989f7685 feat(forge): add Forge tracked channel strip
One note tracker reads the input and Lathe, Temper and Knurl follow it as
modules, in any of six orders, each on or off. Every module gets the
tracker's events delayed by the latency of the modules in front of it.

- One module alone matches its standalone plugin sample for sample in
  every order.
- With Knurl's sub-octave pattern in front, Lathe in Forge follows the
  played note; a standalone Lathe after Knurl reads it an octave low.
- About 72 ms latency against about 206 ms for the three in a row.

The module pages reuse the standalone editors. Register Forge in the
catalog, CI, README, docs and website, and drop the built Ballast from
the site's idea list.
2026-11-06 16:30:23 +01:00
nvrl 340376eee2 refactor: run Lathe, Temper and Knurl as tracked modules
Add a Tracked trait and a Lookahead harness to common-dsp. Each engine is
now its tracker plus a module that follows note events aligned with its
input, so the same module can run behind a shared tracker.

Lathe and Temper output is bit-exact against the old engines. Knurl's
interpolation now reads one newer sample inside the module, so its
latency is one sample longer; the output matches within float rounding.

Each editor exposes body() and toggles() besides show(), so another
editor can draw a module's page.
2026-11-06 16:30:22 +01:00
nvrl 4b1a89626e chore: release Quench 0.1.0 and Ballast 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m25s
Check and Windows preview / check (ballast) (push) Successful in 2m38s
Check and Windows preview / check (braze) (push) Successful in 1m34s
Check and Windows preview / check (crucible) (push) Successful in 1m53s
Check and Windows preview / check (knurl) (push) Successful in 1m45s
Check and Windows preview / check (lathe) (push) Successful in 1m47s
Check and Windows preview / check (patina) (push) Successful in 2m50s
Check and Windows preview / check (planish) (push) Successful in 1m36s
Check and Windows preview / check (quench) (push) Successful in 2m23s
Check and Windows preview / check (rivet) (push) Successful in 1m47s
Check and Windows preview / check (temper) (push) Successful in 1m46s
Check and Windows preview / check (weld) (push) Successful in 2m58s
Check and Windows preview / previews (push) Successful in 1m56s
Check and Windows preview / windows (push) Successful in 2m48s
Independent plugin release / publish (push) Successful in 10s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m47s
- Quench 0.1.0: first release.
- Ballast 0.1.0: first release.
- Mark both released in docs/ideas.md and add them to the release table.
2026-11-06 16:08:23 +01:00
nvrl d93dd942a8 feat(ballast): add Ballast switch-up leveller
Ballast gives every part of a bassline one gain that brings it to the target
loudness, with no compression inside a part. Parts start at note starts,
detected from the audio or taken from MIDI, or on the host's beat grid, so a
patch change on the same note counts too.

- Loudness is K-weighted over each part's first 100 ms, in 10 ms blocks with
  a relative gate, and every part is weighted from rest so the last part does
  not ring into it.
- The audio path is delayed by the tracker's lookahead, the window and a
  5 ms glide, so each gain is in place on its part's first sample.
- Learn takes the next part's loudness as the target. Amount, Range, Measure
  from (leave the sub out), Gate, Output, Bypass.
- Editor shows the last 32 parts in and out, the target, and the spread of
  the last 16 parts.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 16:05:19 +01:00
nvrl d77b88c7a9 feat(common-dsp): add ITU-R BS.1770 K-weighting
KWeighting is one channel of the standard's pre-filter and RLB high-pass,
computed from the analog prototypes so every sample rate is exact. lufs()
turns a mean square summed over channels into LUFS. A full-scale 997 Hz sine
reads -3.01 LUFS within 0.03 LU from 44.1 to 192 kHz.
2026-11-06 16:05:19 +01:00
nvrl 3d316cbe49 feat(quench): add Quench sub-safe clipper
Quench splits the signal with a Linkwitz-Riley crossover and lets the sub
pass unclipped. The top is clipped symmetrically at the ceiling minus the
sub's held peak, so it does not pump at the sub's rate and the sum stays
under the ceiling. What the clip removes is high-passed at the split, so its
products stay out of the low band. Two correction passes and an oversampled
hard clip hold the ceiling; a safety clip catches the downsampler's ringing.

- Controls: Drive, Ceiling, Split, Hold, Soft, Bypass.
- Listen to Quench, a plain clipper at the same settings, or the delta.
- Editor meter compares how much the low band changed for Quench and for a
  plain clip, with a scope of the output, the sub and the top's room.
- Measured: sub level +0.04 dB against -2.2 dB for a plain clip, low-band
  products -35 dB against -16 dB, at 0.5-1.2 dB less RMS.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 15:51:36 +01:00
84 changed files with 14855 additions and 297 deletions

No files matched your search

+7 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench, ballast, forge]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -64,6 +64,9 @@ jobs:
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
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/braze.png --plugin braze
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/quench.png --plugin quench
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/ballast.png --plugin ballast
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/forge.png --plugin forge
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -97,6 +100,9 @@ jobs:
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/build-plugin.py --plugin braze --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin quench --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin ballast --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin forge --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*", "planish-v*", "braze-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*", "quench-v*", "ballast-v*", "forge-v*"]
permissions:
contents: read
Generated
+66
View File
@@ -55,6 +55,25 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "ballast-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "ballast-ui"
version = "0.1.0"
dependencies = [
"ballast-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -183,6 +202,34 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f"
[[package]]
name = "forge-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"knurl-core",
"lathe-core",
"serde",
"serde_json",
"temper-core",
]
[[package]]
name = "forge-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"forge-core",
"knurl-core",
"knurl-ui",
"lathe-core",
"lathe-ui",
"temper-core",
"temper-ui",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -389,6 +436,25 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quench-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "quench-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"quench-core",
]
[[package]]
name = "quote"
version = "1.0.47"
+7 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/quench/core", "plugins/quench/ui", "plugins/ballast/core", "plugins/ballast/ui", "plugins/forge/core", "plugins/forge/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]
@@ -31,3 +31,9 @@ opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
[profile.dev.package.quench-core]
opt-level = 2
[profile.dev.package.ballast-core]
opt-level = 2
[profile.dev.package.forge-core]
opt-level = 2
+18 -3
View File
@@ -30,9 +30,12 @@ note. Each plugin is a separate product with its own version, guide and release.
| **[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) |
| **[Braze](docs/braze/README.md)** | Adds a detuned copy whose beating stays on the tempo grid on every note: reese movement in time | Bass | ≈ 67 ms | [Guide](docs/braze/README.md) · [Changes](docs/braze/CHANGELOG.md) |
| **[Quench](docs/quench/README.md)** | Clips the top as hard as you like while the sub passes clean: the low end keeps its level under a loud master | Master or bass bus, last | < 1 ms (oversampling) | [Guide](docs/quench/README.md) · [Changes](docs/quench/CHANGELOG.md) |
| **[Ballast](docs/ballast/README.md)** | Levels every note or switch-up to one loudness with a single gain each: no compression inside a part | Bass, after the sound design | ≈ 172 ms | [Guide](docs/ballast/README.md) · [Changes](docs/ballast/CHANGELOG.md) |
| **[Forge](docs/forge/README.md)** | Lathe, Temper and Knurl as one channel strip behind one note tracker, in any order: one lookahead, and no module hears Knurl's sub-octave | Bass, after the synth | ≈ 72 ms | [Guide](docs/forge/README.md) · [Changes](docs/forge/CHANGELOG.md) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe, Temper, Knurl, Planish and Braze detect pitch from the audio. They can also take the
Anvil, Lathe, Temper, Knurl, Planish, Braze, Ballast and Forge detect notes from the audio. They can also take the
pitch from MIDI. The order of the plugins matters: see [Signal chain](docs/chain.md).
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
@@ -41,7 +44,9 @@ 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; **planishing**
hammers a surface smooth and even; **brazing** joins two metals with a filler.
hammers a surface smooth and even; **brazing** joins two metals with a filler; **quenching** hardens hot metal in one
fast step; **ballast** is the weight that keeps a ship level; a **forge** is the hearth where
every piece is heated and worked.
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*.
@@ -68,6 +73,13 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
<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><a href="docs/braze/README.md"><img src="docs/braze/assets/editor.png" alt="Braze editor"></a><br><b>Braze</b>: tempo-locked beating</td>
</tr>
<tr>
<td><a href="docs/quench/README.md"><img src="docs/quench/assets/editor.png" alt="Quench editor"></a><br><b>Quench</b>: sub-safe clipper</td>
<td><a href="docs/ballast/README.md"><img src="docs/ballast/assets/editor.png" alt="Ballast editor"></a><br><b>Ballast</b>: switch-up leveller</td>
</tr>
<tr>
<td><a href="docs/forge/README.md"><img src="docs/forge/assets/editor.png" alt="Forge editor"></a><br><b>Forge</b>: tracked channel strip</td>
</tr>
</table>
The previews are rendered headless from the real editor code by
@@ -126,7 +138,7 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/lathe.p
```
Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`,
`lathe`, `temper`, `weld`, `knurl`, `planish` and `braze`. Builds go to `dist/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld`, `knurl`, `planish`, `braze`, `quench`, `ballast` and `forge`. 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.
@@ -166,6 +178,9 @@ plugins/
knurl/ core · ui · plugin
planish/ core · ui · plugin
braze/ core · ui · plugin
quench/ core · ui · plugin
ballast/ core · ui · plugin
forge/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
+17
View File
@@ -0,0 +1,17 @@
# Ballast changelog
## 0.1.0
- Introduce Ballast, a switch-up leveller: every part of a bassline gets one
gain that brings it to the Target loudness. No compression inside a part.
- Parts start at note starts (detected from the audio, or from MIDI) or on the
host's beat grid, from 1 bar to 1/16.
- Loudness is K-weighted (ITU-R BS.1770) over each part's first 100 ms, in
10 ms blocks with a relative gate, and each part is weighted from rest.
- The gain glides over 5 ms and is in place on the part's first sample.
- **Learn** takes the next part's loudness as the target. **Amount**,
**Range**, **Measure from** (leave the sub out of the measurement), **Gate**,
**Output**, **Bypass**.
- Editor: the last 32 parts, their loudness in and out, the target, and the
spread of the last 16 parts.
- About 172 ms of latency: the tracker's lookahead, the window and the glide.
+80
View File
@@ -0,0 +1,80 @@
# Ballast
Ballast levels every note or switch-up of a bassline to one loudness, with a
single gain for each. Current version **0.1.0**.
Riddim and dubstep basslines change patch every few beats. Patches at the same
peak level still differ in how loud they sound, so each switch-up lands at a
different loudness and gets hand-set gain or clipping. A compressor would even
them out but also change their envelope and punch. Ballast measures each part's
loudness and gives the whole part one gain: inside a part nothing moves.
![Ballast](assets/editor.png)
## Install on Windows
Copy `Ballast.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Ballast in the mixer slot of the bass, after its sound
design. Keep the font license with redistributed packages.
## Work with it
1. Choose where a part starts with **Parts from**:
- **Notes**: every note start, detected from the audio, or from MIDI notes
sent to the effect (**Notes from**: AUDIO or MIDI).
- **Grid**: every line of the host's beat grid, from 1 bar to 1/16. Use it
when a switch-up changes the patch on the same note: the note tracker does
not see a new note there.
2. Play the bassline and press **LEARN** just before the switch-up that sits
right in the mix. Its loudness becomes the **Target**. You can also set
Target by hand.
3. Watch the display: one bar per part with its loudness as it came in, and a
mark where Ballast puts it. The marks line up on the target line.
4. Lower **Amount** to keep some of the differences: 50 % halves them.
The panel shows the spread of the last 16 parts, in and out: the standard
deviation of their loudness in dB. In the editor preview four patches spread
by 7 dB come out within 0.4 dB; the rest is what Range left.
Other controls:
- **Range** is the most boost or cut a part gets.
- **Measure from**: the measurement hears only what is above this frequency;
the gain still applies to the whole signal. When Anvil makes the sub on the
bus, set it to Anvil's Split: Anvil replaces the sub anyway, so only the part
above it should decide the gain.
- **Gate** is the note-start threshold (Audio). A part quieter than the gate
keeps the last part's gain instead of being lifted, so rests and tails are
not pulled up.
- **Output** trims the level; **Bypass** crossfades to the delayed input.
### How it measures
A part's loudness is its K-weighted level (ITU-R BS.1770, the weighting behind
LUFS) over its first 100 ms, or over all of it when it is shorter. The level is
counted in 10 ms blocks, and blocks 20 dB under the part's loudest block are
left out. Each part is weighted from rest, as if it played alone, so the last
part's level does not ring into it.
The gain glides to the new value over 5 ms and is in place on the part's first
sample, so the attack already has the new level. Later parts of a long note keep
that gain: Ballast does not follow the note's envelope.
## Timing and limits
Latency is the note tracker's lookahead (about 67 ms), the 100 ms measuring
window and the 5 ms glide, about 172 ms in all, reported to the host for delay
compensation. That is too much for playing live through Ballast: add it for the
mix.
In Grid mode the grid follows the host's beat position while it plays and runs
on at the last tempo when it stops. A note start found by a change of pitch can
come about 20 ms early, inside the gap before the note;
the block gate keeps that gap out of the measurement. Two parts closer than
10 ms are merged.
Ballast decides from the first 100 ms of a part. A patch that swells after
that, or a note whose loudness depends on what plays with it, is levelled by
its start only. It levels loudness, not brightness: two patches at the same
loudness can still differ in tone.
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+24 -6
View File
@@ -1,18 +1,24 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl, Planish and Braze each detect the pitch from the audio they receive. If a plugin earlier
Knurl, Planish, Braze, Ballast and Forge each detect the pitch from the audio they receive. If a plugin earlier
in the chain changes the audio, the pitch detection after it can go wrong.
## Recommended order
```text
bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Weld
bass bus: Anvil → (bus compression, clipping) → Crucible
bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Ballast → Weld
or: synth → Forge (Lathe, Temper, Knurl) → Planish → Braze → Patina → Ballast → Weld
bass bus: Anvil → (bus compression) → Quench → Crucible
master: … → Quench (last, or before a true-peak limiter)
```
- **Lathe and Temper before Knurl.** They hear the plain synth, where the pitch
is clear.
- **Or Forge in their place.** Forge runs Lathe, Temper and Knurl behind one
tracker that reads its input, so the order inside it does not upset the pitch
detection, and the three cost one lookahead (about 72 ms) instead of three.
Planish and Braze then come after Knurl: run them from MIDI.
- **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.
@@ -22,7 +28,12 @@ bass bus: Anvil → (bus compression, clipping) → Crucible
it reads Loose, move Braze to the bus after Anvil, with Low cut at or above
Anvil's Split. There it reads the pitch from Anvil's clean sub, and the sub
stays out of the beating.
- **Knurl last among the tracked plugins.** See below.
- **Ballast after the sound design.** It levels what the patch finally sounds
like, so it comes after the distortion, OTT and Patina. It finds note starts
like the other tracked plugins: after Knurl, use MIDI or Grid. Weld and Anvil
come after it. On the bus with Anvil, set Measure from to Anvil's Split.
- **Knurl last among the pitch-tracked plugins.** See below. Ballast only
needs note starts; after Knurl, run it from MIDI or the grid.
- **Patina after the tracked plugins.** It does not detect pitch, so the order
does not change its result.
- **Weld after every plugin with latency on its track.** Weld can delay its own
@@ -31,6 +42,10 @@ bass bus: Anvil → (bus compression, clipping) → Crucible
that layer late.
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.** For Kick
lock, send the kick's track to Anvil as a sidechain.
- **Quench last on the bus or the master.** It clips the top and leaves the sub
alone, so put it where you would put a clipper. On the bass bus after Anvil,
set its Split at or a little above Anvil's Split: Anvil's sub then passes
Quench untouched. Quench does not detect pitch, so it can sit after Knurl.
- **Crucible at the end of the chain you want to resample.**
## Knurl and pitch detection
@@ -48,12 +63,15 @@ tracker on a saw bass:
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.
and route the bass's MIDI notes to it. Inside Forge this does not apply: its one
tracker reads the input before Knurl.
## Latency
Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper,
Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms. A full chain is
Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms, Ballast
about 172 ms, Quench under 1 ms. Forge holds Lathe, Temper and Knurl in about
72 ms, against about 206 ms for the three plugins in a row. 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
+8 -2
View File
@@ -25,7 +25,10 @@ Patina has `render` for offline WAV evaluation. The shared crates are:
- `common-ui`: palette, embedded fonts, header, panels, knobs, meters.
- `common-dsp`: filters, YIN pitch tracking, the note tracker used by Anvil,
Lathe, Temper, Knurl, Planish and Braze, 4× oversampling, meters and WAV files.
Lathe, Temper, Knurl, Planish, Braze, Ballast and Forge (with the `Tracked`
module trait that lets Lathe, Temper and Knurl run behind one shared tracker),
4× oversampling (also Quench's), BS.1770 K-weighting (Ballast), meters and WAV
files.
The wrappers stay separate workspaces, each with its own lockfile, so the
NIH-plug Git dependency stays out of the root workspace. Host identities and
@@ -45,7 +48,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`, `planish`, `braze`.
`knurl`, `planish`, `braze`, `quench`, `ballast`, `forge`.
`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
@@ -146,6 +149,9 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
| Planish | (new) | `io.narl.bass-foundry.planish` |
| Braze | (new) | `io.narl.bass-foundry.braze` |
| Quench | (new) | `io.narl.bass-foundry.quench` |
| Ballast | (new) | `io.narl.bass-foundry.ballast` |
| Forge | (new) | `io.narl.bass-foundry.forge` |
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
+13
View File
@@ -0,0 +1,13 @@
# Forge changelog
## 0.1.0
- Introduce Forge, one tracked channel strip: Lathe, Temper and Knurl behind a
single note tracker, in any of the six orders, each module on or off.
- Every module follows the note read from the strip's input, so a module after
Knurl no longer hears Knurl's sub-octave.
- One latency of about 72 ms, the same in every order, instead of more than
200 ms for the three plugins in a row.
- A single module on its own gives the same output, sample for sample, as its
standalone plugin.
- Editor: the chain, and each module's standalone editor as its page.
+63
View File
@@ -0,0 +1,63 @@
# Forge
Forge is one tracked channel strip: Lathe, Temper and Knurl run behind a single
note tracker, in the order you choose. Current version **0.1.0**.
Each of the three plugins detects the played note itself. In a row, their
lookaheads add up (Lathe 67 ms, Temper 68 ms, Knurl 71 ms: more than 200 ms),
and a tracker after Knurl hears Knurl's sub-octave and follows a note an octave
low. Forge reads the note once, from its input, and every module follows that
one tracker. In a test with Knurl's sub-octave pattern in front of Lathe, a
standalone Lathe after Knurl followed the note an octave low in every frame;
Lathe inside Forge followed the played note in every frame.
![Forge](assets/editor.png)
## Install on Windows
Copy `Forge.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 Forge in the mixer slot of the bass, right after the
synth. Keep the font license with redistributed packages.
## Work with it
1. Choose **Pitch from** in the header: **AUDIO** detects the note from the
input; **MIDI** follows MIDI notes sent to the effect. All modules use it.
2. The **Chain** row shows the modules in processing order. Click a module's
name to edit it below; **ON** switches it on or off. The button between two
modules swaps them. The default order is Lathe, Temper, Knurl, as in the
standalone chain.
3. Each module's page is its standalone editor: [Lathe](../lathe/README.md),
[Temper](../temper/README.md) and [Knurl](../knurl/README.md) explain the
controls. Temper's **AUTO GAIN** sits in the Chain row while its page is
open.
4. The **Gate** knob on every page is the one shared tracker gate: turning it on
one page turns it on all.
**Bypass** in the header crossfades the whole strip to the delayed input.
## How it works
The tracker reads the strip's input, and the audio is delayed by its lookahead
once. Every module gets the note events delayed by the latency of the modules
in front of it, so each module sees a note start exactly on the attack of the
audio it receives. A single module on its own in Forge gives the same output,
sample for sample, as its standalone plugin, in every position of the chain.
Temper's **Normalize** measures each note's level one lookahead ahead. In Forge
it measures the strip's input, not the output of the modules in front of it.
Lathe and Knurl change the level about equally on every note, so the notes
still reach the shaper at one level.
## Timing and limits
Latency is the tracker's lookahead (about 67 ms), Temper's oversampling filters
(under 1 ms) and Knurl's room for timing jitter (about 4 ms): about 72 ms in
all, reported to the host. A module that is off still delays its audio by its
own latency, so the latency stays the same in every order and with any module
off.
Changing the order while audio plays switches the routing at once and can
click. The standalone plugins stay: use them when you need only one module, or
when another plugin must sit between two of them.
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+79 -4
View File
@@ -237,7 +237,19 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
only. Drift (planned for 0.3).
- **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S
processing.
processing. Threshold, Ratio and Soft/Hard knee from tester feedback (done,
Patina 0.5.0). Next, from the same feedback round:
- An absolute level gate (dBFS) so quiet passages are never cut, if the
tester meant a loudness threshold rather than a prominence threshold.
- Narrow rings on dense or noisy material are under-read: a +12 dB ring
(Q 25) on noise reads only 3–6 dB above the floor, so it is cut by 1–3 dB.
Two causes, measured: the joined-tops envelope lifts the floor to the noise
peaks (about 5 dB above the noise mean), and envelope smoothing loses about
3 dB on a narrow ring. A floor that follows Sharpness (1/3–2 octaves) was
tried and rejected: it lost a resonant lowpass, absorbed broad bumps and cut
plain harmonic tones. Better candidates: join tops only across deep gaps
(harmonic spacing), or a mean-based floor for noise-like regions.
- A finer analysis grid (1024 cells) for narrow rings at high frequencies.
- **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option
that keeps the delay spread under a limit, for plucky layers. Alignment slots
per note or per switch-up. The External
@@ -263,14 +275,14 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
Patina already cut fixed formants on every note, but it also cut a boosted
harmonic as hard (5.7 dB), at a position that moved with the note.
Open: MIDI as the pitch source.
3. Planish (done, released 0.1.0), then Quench and Ballast: they fill the mix
and master gap.
3. Planish (done, released 0.1.0), then Quench and Ballast (both done,
released 0.1.0): they fill the mix and master gap.
- In parallel, from the research findings, in this order: Weld delay
spread (done, Weld 0.3.0); the fatness lane, as the first Assay piece
(prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen
(done, Anvil 0.3.0); Braze (done, 0.1.0); Anvil Kick lock with offset
audition (done, Anvil 0.3.0).
4. Forge, when enough modules exist.
4. Forge (done, released 0.1.0): Lathe, Temper and Knurl behind one tracker.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
@@ -291,6 +303,31 @@ 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.
Built (released 0.1.0, see [Forge](forge/README.md)). Lathe, Temper and Knurl
became modules (a `Tracked` trait in common-dsp: `ahead`, `event`, `process`);
each standalone plugin is now its tracker plus its module, Lathe and Temper
bit-exact against the old engines, Knurl one sample later (its interpolation
needs one newer sample) and equal within float rounding. In the strip one
tracker reads the input; a per-sample record of the tracker's events, note state
and input is replayed to each module, delayed by the latency of the modules in
front of it. Findings:
- One module alone in Forge matches its standalone plugin sample for sample, in
all six orders (tested).
- Knurl's Double-style pattern (0, −12 dB) in front of Lathe: standalone Lathe
after Knurl read the note an octave low in 149 of 149 frames on an A2 saw;
Lathe inside Forge read A2 in 149 of 149.
- Latency 72 ms against 206 ms for the three in a row (48 kHz).
- Temper's Normalize looks one lookahead ahead; in the strip it reads the strip
input, not the modules in front of it. Fine while those change every note's
level alike.
Next for Forge: the *Tension* macro over Knurl (and later Anneal) at matched
loudness; Planish and Braze as modules (both are tracked, and Braze after
Knurl is the next tracker that hears the sub-octave); a short crossfade when
the order changes; one shared modulation source (per-note envelope or LFO in
cycles) that every module can read.
### Braze — tempo-locked beating (problem 20)
Brazing joins two metals with a filler. Braze adds a detuned copy of the bass
@@ -328,6 +365,29 @@ 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.
Built (released 0.1.0, see [Quench](quench/README.md)). Design checks, made
in a prototype before the plugin:
- Splitting and giving the top the instantaneous room, clamp(top, −C − sub,
C − sub), is exactly a plain clip of the sum: the error is the same signal.
The sub-safe part has to come from somewhere else.
- Feeding the clip error back through 1 − lowpass diverges: the complement of
an LR4 lowpass peaks at 1.5× near the split.
- What works: clip the top symmetrically at the ceiling minus the sub's held
peak (no pumping at the sub's rate, the sum stays under the ceiling), then
high-pass the clip error at the split so its products below the split go.
Two clip-and-high-pass passes and an oversampled hard clip hold the ceiling.
Measured: sub level +0.04 dB against −2.2 dB for a plain clip; low-band
products −35 dB against −16 dB. Cost: 0.5–1.2 dB less RMS at the same
ceiling.
- Where it does not help: a top whose fundamental lies within an octave of the
split is partly in the sub band (saw at 98 Hz over a 49 Hz sub: about the same
as a plain clip).
- Next ideas: link the channels' sub peak and offer mono below the split
(with Gauge); give the top the room on the sub's far side too (asymmetric)
with the error still high-passed, and measure whether that wins back the
loudness without pumping; a true-peak ceiling.
### Scribe — audio to MIDI (problem 18)
A scribe marks lines on metal. Scribe runs the note tracker on a bass and
@@ -353,6 +413,21 @@ the same loudness without compression changing its character. Earlier working
name: Hit Leveler. Pairs with Anvil: Anvil levels the sub, Ballast the whole
patch.
Built (released 0.1.0, see [Ballast](ballast/README.md)): one gain per part,
parts from note starts or the host grid (a patch change on the same note is not
a new note to the tracker), K-weighted loudness of the first 100 ms, Learn for
the reference. Found while building it:
- A note start from a pitch change comes about 20 ms early, in the gap before
the note; a plain mean over the window then reads the part ~0.9 dB quiet.
Fixed with 10 ms blocks and a relative gate (−20 dB under the loudest block).
- The K-weighting filters ring the last part into the next one: a near-silent
part after a loud one read −18 LUFS instead of under −60. Fixed by weighting
every part from rest.
- Next ideas: a brightness match next to loudness (problem 2 names both; with
Lathe or Patina doing the EQ), a "follow" option for long notes that swell,
and switch-up detection from timbre change instead of the grid.
### Bellows — pitch-tracked resonator
A comb or Karplus-style resonator tuned to the detected note. It turns noise,
+5
View File
@@ -1,5 +1,10 @@
# Knurl changelog
## Unreleased
- The engine is now a module that also runs inside Forge. The sound is
unchanged; the latency is one sample longer.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.knurl`. Hosts see this as a new
+17
View File
@@ -1,5 +1,22 @@
# Patina changelog
## 0.5.0
- Each band now works like a small compressor. Add **Ratio** (1:1 to ∞:1,
default ∞:1): low ratios leave small bumps nearly alone and still cut big ones
hard. ∞:1 is the old behaviour.
- Replace Selectivity (%) with **Threshold** (0–18 dB above the local floor,
default 3 dB, which equals the old default of 50 %).
- Add a **Soft/Hard knee** switch. Soft widens the knee from 3 dB to 6 dB;
Hard uses a 1 dB knee and half the Attack time.
- Show the gain curve (bump height in, cut out) under the knobs.
- Mix and Output move to the slider panel under the plot.
- Retune the Vocal harshness, Snare ring, Bright synth and Bass line presets
with Ratio and more Depth.
- Breaking: FL Studio projects saved earlier load Threshold, Ratio and Knee at
their defaults. Settings files for `patina-render` with `selectivity` still
load and convert.
## 0.4.0
- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and
+28 -13
View File
@@ -1,7 +1,7 @@
# Patina
Patina smooths a sound’s rough surface without erasing its character. Formerly
**Resonance Compressor**; current version **0.4.0**.
**Resonance Compressor**; current version **0.5.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
@@ -20,18 +20,30 @@ redistributed packages.
## Work with it
Three controls decide what happens:
Each band works like a small compressor. It compares its level with the local floor
around it and cuts only the part that sticks out:
- **Depth** is the largest cut, in dB. 0 dB does nothing.
- **Depth** is the largest cut per band, in dB. 0 dB does nothing.
- **Sharpness** sets what counts as a resonance. Low values follow broad,
smooth bumps (honk, boxiness). High values target narrow rings and whistles.
- **Selectivity** sets how far a bump must stand out before it is cut. Low
values treat any bump; high values treat only the worst.
- **Threshold** is how far (dB) a band must rise above its neighbours before it
is cut. Low values treat any bump; high values treat only the worst.
- **Ratio** is how hard a bump above the threshold is cut. At n:1 a bump ends
1/n as far above the threshold as it started. Low ratios leave small bumps
nearly alone and still cut big ones; ∞:1 flattens every bump to the
threshold (this is how Patina worked before 0.5).
- **Knee**: Soft fades cuts in around the threshold. Hard starts them right at
the threshold and uses half the Attack time.
Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show
cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep
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.
The small graph under the knobs shows the result: bump height above the floor
in, cut out, with the threshold and the Depth limit.
Start with Depth around 6 dB. Raise Threshold until only the harsh parts show
cuts, then use Delta to hear what is removed. To hit big resonances hard and
leave small ones alone, raise Depth to 12–20 dB and set Ratio to 2:1–4:1.
Restrict Low/High frequency to keep 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
@@ -61,7 +73,7 @@ on the plot drag the processing limits.
On the plot, the grey fill is the measured input. The bright line is what the
detector hears (its smoothed spectral envelope); the dim line is what it expects
there. Accent-coloured cuts appear where the bright line rises above the dim one
by more than Selectivity allows. The blue curve is the focus/protect shape.
by more than Threshold allows. The blue curve is the focus/protect shape.
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
then return to normal monitoring to decide whether the music improved.
@@ -83,8 +95,9 @@ window size persist; Delta does not.
4. The reference is a robust local straight-line fit over 2–3 octaves: spectral
tilt is expected, bumps cannot raise their own reference, and empty regions
(below a fundamental, beyond a filter cutoff) are ignored.
5. Envelope minus reference, above the Selectivity threshold, becomes the cut,
limited by Depth, smoothed with Attack/Release, and linked across stereo.
5. Envelope minus reference is the band's height above its floor. Above
Threshold it becomes a cut through the Knee and Ratio curve, limited by
Depth, smoothed with Attack/Release, and linked across stereo.
## Timing and limits
@@ -142,7 +155,9 @@ The CLAP ID is now `io.narl.bass-foundry.patina` (earlier builds used
`io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so
add it again in projects saved with an earlier build. Version 0.2 replaced
Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth,
Sharpness, and Selectivity. Delete the old plugin file, place `Patina.clap`, and
Sharpness, and Selectivity; version 0.5 replaced Selectivity with Threshold
(dB), Ratio and Knee, so projects saved earlier load Threshold at its default.
Delete the old plugin file, place `Patina.clap`, and
rescan FL Studio.
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 417 KiB

+16
View File
@@ -0,0 +1,16 @@
# Quench changelog
## 0.1.0
- Introduce Quench, a clipper that clips the top and lets the sub pass clean.
- A Linkwitz–Riley split at **Split** (40–300 Hz). The top is clipped at the
ceiling minus the sub's held peak; **Hold** sets how long that peak holds.
- What the clip removes is high-passed at Split, so its products stay out of
the low band. Two correction passes and an oversampled hard clip hold the
ceiling; a safety clip catches the downsampler's ringing.
- **Drive**, **Ceiling**, **Soft** (knee of the top's clip), **Bypass**.
- Listen to **Quench**, a **Plain** clipper at the same settings, or the
**Delta** that Quench takes away. Not saved.
- Editor: how much the low band changed for Quench and for a plain clip, how
far the top is clipped, the sub's peak and the room it leaves, and a scope.
- 4× oversampling; 37 samples of latency.
+104
View File
@@ -0,0 +1,104 @@
# Quench
Quench clips the top of the mix as hard as you like and lets the sub pass clean.
Current version **0.1.0**.
Riddim and dubstep masters are clipped hard. A plain clipper clips the sum of
the sub and everything above it. Near the sub's peaks only one side of the top
gets clipped, so the clipping takes energy out at the sub's rate: the sub loses
level, and the low end fills with products that were not in the mix. Quench
splits the signal at a frequency you set, never clips the sub, and clips only
the top, in the room the sub leaves under the ceiling.
![Quench](assets/editor.png)
## Install on Windows
Copy `Quench.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 Quench last on the master or on the bass bus. Keep the
font license with redistributed packages.
## Work with it
1. Put Quench where you would put a clipper: last on the master, or last on
the bass bus. Before a true-peak limiter is fine.
2. Set **Ceiling**, the highest output peak.
3. Set **Split** above the highest sub note, or at Anvil's Split when Anvil
makes the sub. Everything below Split passes without clipping.
4. Raise **Drive** until the top clips as hard as you want. The panel shows
how far the top is clipped.
5. Press **PLAIN** to hear a plain clipper at the same settings, then
**QUENCH** again. **DELTA** plays only what Quench takes away. The listen
choice is not saved and goes back to QUENCH when the editor closes.
The panel shows:
- **Low end changed by**: how much the band below Split differs from the
input, against the sub's level, for Quench and for a plain clip of the same
signal. Lower is cleaner. It reads "—" when no sub plays.
- **Top clipped by**: how far the top's loudest peak went past its room.
- **Sub peak · room left**: the sub's peak after Drive and the room it leaves
the top under the ceiling.
- The scope: the output (grey), the sub band (blue), and the sub plus or minus
the top's room (thin lines). The output stays between the thin lines.
Other controls:
- **Hold** keeps the room the sub's peak needs for this long. With a hold
longer than half a period of the lowest note (17 ms at 30 Hz), the top's
clip level stays steady through the sub's cycle and the top does not pump
with the sub. Shorter holds give the top a little more room between sub
peaks, and that room then changes at the sub's rate.
- **Soft** bends the top's clip: 0 % is a hard clip, 100 % a soft curve from
the start. The ceiling stays a hard limit.
- **Bypass** crossfades to the input, delayed by the latency.
When the sub alone reaches the ceiling, the top has no room left. The panel
then warns: lower Drive or the sub. A safety clip still holds the ceiling, and
that clip does cut the sub.
### How it works
A Linkwitz–Riley crossover splits the signal at Split. The sub passes. The top
is clipped symmetrically at the ceiling minus the sub's held peak, so the sum
of sub and top never goes over the ceiling.
Clipping the top also makes products below Split, for example difference
tones between its partials. Quench high-passes what the clip removed at Split,
so those products do not reach the low band. High-passing lets a few peaks
overshoot. Two correction passes clip those again and high-pass the correction,
and a final hard clip at the ceiling takes the small rest. All of this runs at
4× oversampling. A safety clip after the downsampler catches the filters'
ringing; it reads "idle" or a few hundredths of a dB.
Measured on a 50 Hz sub at −10.5 dBFS under a top of five unrelated partials
peaking about 6 dB over the ceiling:
| | Sub level | Low-band products |
| --- | --- | --- |
| Plain clip | −2.2 dB | −16 dB |
| Quench | +0.04 dB | −35 dB |
Low-band products are everything below 110 Hz except the sub, against the
sub's level.
Quench pays for this in loudness. The top gets the room on the sub's quieter
side only, so at the same ceiling Quench measured 0.5–1.2 dB lower in RMS
than a plain clip. Add Drive to make up for it.
## Timing and limits
Latency is the oversampling filters: 37 samples (0.8 ms at 48 kHz), reported
to the host for delay compensation.
The split is a slope, not a wall. A top whose own fundamental lies near
Split, for example a distorted patch an octave above a 49 Hz sub, is partly in
the sub band. That part passes unclipped and takes up room, and Quench helps
less there: on a saw top at 98 Hz it changed the low end about as much as a
plain clip. Keep the top's lowest partial at least an octave above Split.
Quench keeps the ceiling on sample peaks. True peaks between samples can
still go a little over it: put a true-peak limiter after Quench if the master
needs one. Each channel is clipped on its own; Quench does not link the
channels or turn the sub to mono.
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+4 -1
View File
@@ -8,7 +8,7 @@ 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.4.0` | `patina-windows-x86_64-0.4.0.zip` |
| Patina | `patina-v0.5.0` | `patina-windows-x86_64-0.5.0.zip` |
| Anvil | `anvil-v0.3.0` | `anvil-windows-x86_64-0.3.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` |
@@ -17,6 +17,9 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| 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` |
| Braze | `braze-v0.1.0` | `braze-windows-x86_64-0.1.0.zip` |
| Quench | `quench-v0.1.0` | `quench-windows-x86_64-0.1.0.zip` |
| Ballast | `ballast-v0.1.0` | `ballast-windows-x86_64-0.1.0.zip` |
| Forge | `forge-v0.1.0` | `forge-windows-x86_64-0.1.0.zip` |
## Make a release
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "ballast-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+474
View File
@@ -0,0 +1,474 @@
//! Levels each part of a bassline, a note or a step of the beat grid, to one loudness with a
//! single gain per part. No compression: the gain does not move inside a part, so a patch
//! keeps its own envelope and punch.
//!
//! A part's loudness is the K-weighted (ITU-R BS.1770) mean square of its first 100 ms, or
//! of all of it when it is shorter, counted in 10 ms blocks; blocks 20 dB under the loudest
//! one are left out, so a gap at the start does not pull the reading down. Each part is
//! weighted from rest, as if it played alone. The audio path is delayed by the note tracker's lookahead
//! plus that window plus a 5 ms ramp, so every part's gain is known before its attack leaves
//! the plugin, and the ramp to it ends exactly on the attack.
mod settings;
use common_dsp::loudness::{lufs, KWeighting};
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db};
pub use settings::{
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
};
/// Each part is measured over this much of its start.
pub const WINDOW_MS: f32 = 100.;
/// The gain glides to a new part's value over this long, ending on its first sample.
pub const RAMP_MS: f32 = 5.;
/// A boundary closer than this to the last one is ignored: the parts are merged.
const MIN_PART_MS: f32 = 10.;
/// The window is measured in blocks of this length.
const BLOCK_MS: f32 = 10.;
const BLOCKS: usize = (WINDOW_MS / BLOCK_MS) as usize;
/// Blocks this far under the part's loudest block do not count: a note start found by a
/// pitch change can come early, inside the gap before the note.
const RELATIVE_GATE_DB: f64 = 20.;
/// Parts shown in the editor.
pub const HISTORY: usize = 32;
/// Parts used for the spread readout.
const SPREAD: usize = 16;
const PENDING: usize = 32;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Tempo the grid runs at before the host reports one.
const FALLBACK_BPM: f64 = 120.;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Part {
/// Loudness of the part's measured start, in LUFS.
pub loudness: f32,
/// Gain given to the part, in dB.
pub gain: f32,
/// Under the gate: the part kept the previous gain.
pub gated: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Latest parts, oldest first; the first `count` entries are valid.
pub history: [Part; HISTORY],
pub count: usize,
/// Gain applied at the output right now, in dB.
pub gain: f32,
/// Standard deviation of the loudness of the last 16 parts over the gate, before and after
/// Ballast. NaN with fewer than two.
pub spread_in: f32,
pub spread_out: f32,
/// Waiting for the next part to take as the target.
pub learning: bool,
/// The loudness last taken by Learn, and a number that changes each time one is taken.
pub learned: f32,
pub learn_id: u32,
pub tempo: f64,
pub synced: bool,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
history: [Part::default(); HISTORY],
count: 0,
gain: 0.,
spread_in: f32::NAN,
spread_out: f32::NAN,
learning: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
synced: false,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Gain in dB that brings `loudness` to the target, at `amount` (0–1), within ±`range`.
pub fn part_gain(loudness: f32, target: f32, amount: f32, range: f32) -> f32 {
((target - loudness) * amount).clamp(-range, range)
}
#[derive(Clone, Copy, Default)]
struct Pending {
start: u64,
gain: f32,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
tracker: Tracker,
lookahead: usize,
window: usize,
ramp: usize,
min_part: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
weighting: [KWeighting; 2],
from: [[Svf; 2]; 2],
from_coef: SvfCoef,
/// Samples of the aligned stream: the input delayed by the tracker's lookahead.
clock: u64,
open: bool,
part_start: u64,
block: usize,
blocks: [(f64, usize); BLOCKS],
measured: usize,
last_gain: f32,
queue: [Pending; PENDING],
queue_head: usize,
queue_len: usize,
gain: f32,
ramp_from: f32,
ramp_to: f32,
ramp_left: usize,
history: [Part; HISTORY],
history_at: usize,
history_len: usize,
learn: bool,
learned: f32,
learn_id: u32,
tempo: f64,
/// Beat position of the aligned stream.
beats: f64,
synced: bool,
cell: i64,
output: f32,
bypass: f32,
smooth: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=384000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let samples = |ms: f32| ((rate * ms * 0.001).round() as usize).max(1);
let (window, ramp) = (samples(WINDOW_MS), samples(RAMP_MS));
let mut e = Self {
rate,
channels,
settings: Settings::default(),
tracker,
lookahead,
window,
ramp,
min_part: samples(MIN_PART_MS),
delay: vec![[0.; 2]; lookahead + window + ramp + 1],
delay_at: 0,
weighting: [KWeighting::new(rate); 2],
from: [[Svf::default(); 2]; 2],
from_coef: SvfCoef::IDENTITY,
clock: 0,
open: false,
part_start: 0,
block: samples(BLOCK_MS),
blocks: [(0., 0); BLOCKS],
measured: 0,
last_gain: 0.,
queue: [Pending::default(); PENDING],
queue_head: 0,
queue_len: 0,
gain: 1.,
ramp_from: 1.,
ramp_to: 1.,
ramp_left: 0,
history: [Part::default(); HISTORY],
history_at: 0,
history_len: 0,
learn: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
beats: 0.,
synced: false,
cell: 0,
output: 1.,
bypass: 0.,
smooth: coefficient(rate, 10.),
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
/// Lookahead of the tracker, the measuring window and the ramp.
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.window + self.ramp) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
if s.from != self.settings.from || self.fresh {
self.from_coef = if s.from > FROM_MIN {
SvfCoef::highpass(self.rate, s.from, Q)
} else {
SvfCoef::IDENTITY
};
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Host tempo in BPM and, while playing, the beat position at the block's first sample.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
if let Some(t) = tempo.filter(|t| t.is_finite() && *t > 0.) {
self.tempo = t;
}
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The parts are cut on the aligned stream, `lookahead` samples behind the input.
self.beats = p - self.lookahead as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Takes the loudness of the next part over the gate as the new target.
pub fn learn(&mut self) {
self.learn = true;
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
self.delay_at = 0;
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
self.clock = 0;
self.open = false;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
self.last_gain = 0.;
self.queue_len = 0;
self.gain = 1.;
self.ramp_left = 0;
self.history_len = 0;
self.learn = false;
self.fresh = true;
let s = self.settings;
let _ = self.set_settings(s);
}
/// Mean square of the measured blocks, without those far under the loudest.
fn gated_mean(&self) -> f64 {
let used = self.measured.div_ceil(self.block).min(BLOCKS);
let blocks = &self.blocks[..used];
let loudest = blocks
.iter()
.map(|(s, n)| s / (*n).max(1) as f64)
.fold(0., f64::max);
let floor = loudest * 10f64.powf(-RELATIVE_GATE_DB / 10.);
let (sum, n) = blocks
.iter()
.filter(|(s, n)| s / (*n).max(1) as f64 >= floor)
.fold((0., 0), |(a, b), (s, n)| (a + s, b + n));
sum / n.max(1) as f64
}
fn close(&mut self) {
self.open = false;
let s = self.settings;
let loudness = lufs(self.gated_mean());
let gated = loudness < s.gate;
let gain = if gated {
self.last_gain
} else {
part_gain(loudness, s.target, s.amount * 0.01, s.range)
};
if !gated {
self.last_gain = gain;
if std::mem::take(&mut self.learn) {
self.learned = loudness;
self.learn_id = self.learn_id.wrapping_add(1);
}
}
if self.queue_len < PENDING {
let at = (self.queue_head + self.queue_len) % PENDING;
self.queue[at] = Pending {
start: self.part_start,
gain,
};
self.queue_len += 1;
}
self.history[self.history_at] = Part {
loudness,
gain,
gated,
};
self.history_at = (self.history_at + 1) % HISTORY;
self.history_len = (self.history_len + 1).min(HISTORY);
}
// Channels index the delayed frame, the filters and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let mut started = false;
self.tracker.process(mono, |e| {
if matches!(e, NoteEvent::Start { .. }) {
started = true;
}
});
let len = self.delay.len();
self.delay[self.delay_at] = x;
let aligned = self.delay[(self.delay_at + len - self.lookahead) % len];
self.delay_at = (self.delay_at + 1) % len;
let delayed = self.delay[self.delay_at];
// Grid lines are counted on the aligned stream even in Notes mode, so switching modes
// does not cut a part.
let cell = (self.beats / self.settings.grid.quarters()).floor() as i64;
self.beats += self.tempo / 60. / self.rate as f64;
let crossed = cell != self.cell;
self.cell = cell;
let boundary = match self.settings.segment {
Segment::Notes => started,
Segment::Grid => crossed,
};
if boundary && !(self.open && self.measured < self.min_part) {
if self.open {
self.close();
}
self.open = true;
self.part_start = self.clock;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
// Each part is weighted from rest, as if it played alone: the filters' memory of
// the last part would otherwise ring into this one.
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
}
let mut power = 0f64;
for c in 0..self.channels {
let f = &mut self.from[c];
let v = f[0].process(&self.from_coef, aligned[c]);
let v = f[1].process(&self.from_coef, v);
let w = self.weighting[c].process(v) as f64;
power += w * w;
}
if self.open {
let b = &mut self.blocks[(self.measured / self.block).min(BLOCKS - 1)];
b.0 += power;
b.1 += 1;
self.measured += 1;
if self.measured >= self.window {
self.close();
}
}
// A part's ramp starts `ramp` samples before its first sample reaches the output.
if self.queue_len > 0 {
let next = self.queue[self.queue_head];
if self.clock >= next.start + self.window as u64 {
self.queue_head = (self.queue_head + 1) % PENDING;
self.queue_len -= 1;
self.ramp_from = self.gain;
self.ramp_to = db_to_gain(next.gain);
self.ramp_left = self.ramp;
}
}
if self.ramp_left > 0 {
self.ramp_left -= 1;
let t = 1. - self.ramp_left as f32 / self.ramp as f32;
let t = t * t * (3. - 2. * t);
self.gain = self.ramp_from + (self.ramp_to - self.ramp_from) * t;
}
self.clock += 1;
let s = self.settings;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
if (f32::from(s.bypass) - self.bypass).abs() < 1e-6 {
self.bypass = f32::from(s.bypass);
}
let mut out = [0.; 2];
for c in 0..self.channels {
let wet = delayed[c] * self.gain * self.output;
out[c] = if self.bypass >= 1. {
delayed[c]
} else {
wet + (delayed[c] - wet) * self.bypass
};
}
out
}
fn spread(&self, after: bool) -> f32 {
let (mut n, mut sum, mut sq) = (0usize, 0f64, 0f64);
for i in 0..self.history_len {
if n == SPREAD {
break;
}
let p = self.history[(self.history_at + HISTORY - 1 - i) % HISTORY];
if p.gated {
continue;
}
let v = (p.loudness + if after { p.gain } else { 0. }) as f64;
n += 1;
sum += v;
sq += v * v;
}
if n < 2 {
return f32::NAN;
}
let mean = sum / n as f64;
((sq / n as f64 - mean * mean).max(0.)).sqrt() as f32
}
/// Readings for the editor. Never allocates.
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
count: self.history_len,
gain: gain_to_db(self.gain),
spread_in: self.spread(false),
spread_out: self.spread(true),
learning: self.learn,
learned: self.learned,
learn_id: self.learn_id,
tempo: self.tempo,
synced: self.synced,
latency: self.latency_samples(),
sample_rate: self.rate,
..AnalyzerFrame::default()
};
let first = (self.history_at + HISTORY - self.history_len) % HISTORY;
for i in 0..self.history_len {
f.history[i] = self.history[(first + i) % HISTORY];
}
f
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Host-independent controls. Loudness in LUFS, gains in dB, amounts in percent.
use serde::{Deserialize, Serialize};
pub const TARGET_MIN: f32 = -40.;
pub const TARGET_MAX: f32 = 0.;
pub const RANGE_MAX: f32 = 24.;
/// Lowest frequency the measurement can start from. At or under this the measurement hears
/// the full band.
pub const FROM_MIN: f32 = 20.;
pub const FROM_MAX: f32 = 300.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Note starts are detected from the incoming bass.
#[default]
Audio,
/// Note starts come from MIDI note input.
Midi,
}
/// Where one leveled part ends and the next begins.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Segment {
/// Every note start.
#[default]
Notes,
/// Every line of the host's beat grid, so a patch change on the same note counts too.
Grid,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Grid {
Bar,
Half,
#[default]
Quarter,
Eighth,
Sixteenth,
}
impl Grid {
pub const ALL: [Grid; 5] = [
Grid::Bar,
Grid::Half,
Grid::Quarter,
Grid::Eighth,
Grid::Sixteenth,
];
/// Length in quarter notes.
pub fn quarters(self) -> f64 {
match self {
Grid::Bar => 4.,
Grid::Half => 2.,
Grid::Quarter => 1.,
Grid::Eighth => 0.5,
Grid::Sixteenth => 0.25,
}
}
pub fn label(self) -> &'static str {
match self {
Grid::Bar => "1 BAR",
Grid::Half => "1/2",
Grid::Quarter => "1/4",
Grid::Eighth => "1/8",
Grid::Sixteenth => "1/16",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
pub segment: Segment,
pub grid: Grid,
/// Loudness every part should land at, measured over its first 100 ms.
pub target: f32,
/// Share of the correction that is applied.
pub amount: f32,
/// Most boost or cut, in dB.
pub range: f32,
/// The measurement hears only what is above this frequency; the gain still applies to
/// the full band. Set it to Anvil's Split when Anvil makes the sub.
pub from: f32,
/// Note-start threshold (Audio source), and the loudness under which a part keeps the
/// last gain instead of being lifted.
pub gate: f32,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
segment: Segment::Notes,
grid: Grid::Quarter,
target: -14.,
amount: 100.,
range: 12.,
from: FROM_MIN,
gate: -48.,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.target,
self.amount,
self.range,
self.from,
self.gate,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (TARGET_MIN..=TARGET_MAX).contains(&self.target)
&& (0.0..=100.).contains(&self.amount)
&& (0.0..=RANGE_MAX).contains(&self.range)
&& (FROM_MIN..=FROM_MAX).contains(&self.from)
&& (-60.0..=-20.).contains(&self.gate)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+332
View File
@@ -0,0 +1,332 @@
use ballast_core::{part_gain, Engine, Grid, Segment, Settings, Source, RAMP_MS, WINDOW_MS};
use common_dsp::loudness::{lufs, KWeighting};
use std::f64::consts::TAU;
const RATE: f32 = 48000.;
const BPM: f64 = 120.;
/// One quarter at 120 BPM.
const QUARTER: usize = 24000;
/// Two patches a switch-up might alternate: a loud saw and a quiet sine, an octave apart.
fn patch(i: usize, which: usize) -> f32 {
let t = i as f64 / RATE as f64;
if which.is_multiple_of(2) {
let phase = (55. * t).fract();
(0.5 * (2. * phase - 1.)) as f32
} else {
(0.1 * (TAU * 110. * t).sin()) as f32
}
}
fn grid_engine(s: Settings) -> Engine {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
segment: Segment::Grid,
grid: Grid::Quarter,
..s
})
.unwrap();
e
}
/// Runs `input` through the engine in 512-sample blocks with the transport playing from 0.
fn run(e: &mut Engine, input: &[f32]) -> Vec<f32> {
let mut out = Vec::with_capacity(input.len());
for (i, &v) in input.iter().enumerate() {
if i % 512 == 0 {
e.set_transport(Some(BPM), Some(i as f64 / RATE as f64 * BPM / 60.));
}
out.push(e.process_sample([v, v])[0]);
}
out
}
/// K-weighted loudness of both channels (the same signal) over `window` samples from `start`.
fn loudness(x: &[f32], start: usize, window: usize) -> f32 {
let mut k = KWeighting::new(RATE);
// Settle the filter on what came before, as the engine's own measurement does.
for &v in &x[start.saturating_sub(4800)..start] {
k.process(v);
}
let sum: f64 = x[start..start + window]
.iter()
.map(|&v| (k.process(v) as f64).powi(2))
.sum();
lufs(2. * sum / window as f64)
}
fn window() -> usize {
(RATE * WINDOW_MS * 0.001) as usize
}
#[test]
fn every_part_lands_at_the_target() {
let target = -18.;
let mut e = grid_engine(Settings {
target,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let parts = 10;
let input: Vec<f32> = (0..parts * QUARTER + latency)
.map(|i| patch(i, i / QUARTER))
.collect();
let out = run(&mut e, &input);
for p in 2..parts {
let start = p * QUARTER;
let before = loudness(&input, start, window());
let after = loudness(&out, start + latency, window());
assert!(
(after - target).abs() < 0.3,
"part {p}: {before:.1} → {after:.1}"
);
}
let f = e.take_analyzer();
assert!(f.spread_in > 5., "{}", f.spread_in);
assert!(f.spread_out < 0.1, "{}", f.spread_out);
}
#[test]
fn the_gain_holds_still_inside_a_part_and_is_in_place_on_its_attack() {
let mut e = grid_engine(Settings {
target: -18.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let ramp = (RATE * RAMP_MS * 0.001) as usize;
let input: Vec<f32> = (0..6 * QUARTER + latency)
.map(|i| patch(i, i / QUARTER) + 0.2)
.collect();
let out = run(&mut e, &input);
for p in 2..5 {
let start = p * QUARTER;
assert!(input[start].abs() > 0.05);
let gain = out[start + latency] / input[start];
for n in start..start + QUARTER - ramp {
if input[n].abs() < 0.05 {
continue;
}
let g = out[n + latency] / input[n];
assert!(
(g / gain - 1.).abs() < 1e-4,
"part {p}, sample {n}: {g} vs {gain}"
);
}
}
}
#[test]
fn note_starts_from_the_audio_cut_the_parts() {
let target = -18.;
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
target,
..Settings::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
// 300 ms notes with 100 ms gaps, alternating the two patches.
let note = 19200;
let input: Vec<f32> = (0..8 * note + latency)
.map(|i| {
if i % note < 14400 && i < 8 * note {
patch(i, i / note)
} else {
0.
}
})
.collect();
let out = run(&mut e, &input);
let f = e.take_analyzer();
let parts = f.history[..f.count].iter().filter(|p| !p.gated).count();
assert_eq!(parts, 8, "{:?}", &f.history[..f.count]);
for n in 2..8 {
// The tracker finds the start a few milliseconds in; measure past that.
let start = n * note + 960;
let after = loudness(&out, start + latency, window());
assert!((after - target).abs() < 0.5, "note {n}: {after:.1}");
}
}
#[test]
fn midi_note_starts_cut_legato_parts() {
let target = -18.;
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
target,
source: Source::Midi,
..Settings::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
let note = QUARTER;
let mut out = Vec::new();
let input: Vec<f32> = (0..6 * note + latency)
.map(|i| patch(i, i / note))
.collect();
for (i, &v) in input.iter().enumerate() {
if i % note == 0 {
// Legato: the new note starts before the old one ends, on the same pitch.
e.note_on(33);
if i > 0 {
e.note_off(33);
e.note_on(33);
}
}
out.push(e.process_sample([v, v])[0]);
}
for n in 2..6 {
let after = loudness(&out, n * note + latency, window());
assert!((after - target).abs() < 0.3, "note {n}: {after:.1}");
}
}
#[test]
fn quiet_parts_keep_the_last_gain() {
let mut e = grid_engine(Settings {
target: -18.,
gate: -40.,
..Settings::default()
});
// A loud part, then a part far under the gate, then the loud patch again. The parts change
// on zero crossings of a 110 Hz sine, so no step rings into the quiet part.
let input: Vec<f32> = (0..4 * QUARTER)
.map(|i| {
let v = 0.5 * (TAU * 110. * i as f64 / RATE as f64).sin() as f32;
if i / QUARTER == 2 {
v * 0.001
} else {
v
}
})
.collect();
run(&mut e, &input);
let f = e.take_analyzer();
let parts = &f.history[..f.count];
// The first part is the silence before the input reaches the measurement.
let quiet = 1 + parts[1..]
.iter()
.position(|p| p.gated)
.expect("a gated part");
assert_eq!(parts[quiet].gain, parts[quiet - 1].gain);
}
#[test]
fn amount_and_range_scale_the_correction() {
assert_eq!(part_gain(-10., -14., 1., 12.), -4.);
assert_eq!(part_gain(-10., -14., 0.5, 12.), -2.);
assert_eq!(part_gain(-40., -14., 1., 12.), 12.);
assert_eq!(part_gain(0., -14., 1., 6.), -6.);
let mut e = grid_engine(Settings {
target: -18.,
amount: 50.,
..Settings::default()
});
let input: Vec<f32> = (0..4 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
run(&mut e, &input);
let f = e.take_analyzer();
for p in &f.history[1..f.count] {
let full = -18. - p.loudness;
assert!((p.gain - 0.5 * full).abs() < 1e-3, "{p:?}");
}
}
#[test]
fn learn_takes_the_next_part_as_the_target() {
let mut e = grid_engine(Settings::default());
let input: Vec<f32> = (0..3 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
run(&mut e, &input[..QUARTER + 100]);
e.learn();
assert!(e.take_analyzer().learning);
let before = e.take_analyzer().learn_id;
run(&mut e, &input[QUARTER + 100..]);
let f = e.take_analyzer();
assert!(!f.learning);
assert_ne!(f.learn_id, before);
let taken = f.history[..f.count]
.iter()
.find(|p| p.loudness == f.learned)
.expect("learned a part's loudness");
assert!(!taken.gated);
}
#[test]
fn the_measurement_can_leave_out_the_sub() {
// The same top, once with a loud sub under it.
let top = |i: usize| (0.05 * (TAU * 1000. * i as f64 / RATE as f64).sin()) as f32;
let sub = |i: usize| (0.5 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32;
let gains = |from: f32| {
let mut e = grid_engine(Settings {
target: -24.,
range: 24.,
from,
..Settings::default()
});
let input: Vec<f32> = (0..4 * QUARTER)
.map(|i| {
top(i)
+ if (i / QUARTER).is_multiple_of(2) {
sub(i)
} else {
0.
}
})
.collect();
run(&mut e, &input);
let f = e.take_analyzer();
let p = &f.history[..f.count];
(p[p.len() - 2].gain - p[p.len() - 1].gain).abs()
};
assert!(gains(20.) > 10.);
assert!(gains(200.) < 1., "{}", gains(200.));
}
#[test]
fn bypass_is_the_input_delayed_by_the_latency() {
let mut e = grid_engine(Settings {
bypass: true,
target: -30.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let lookahead = latency - window() - (RATE * RAMP_MS * 0.001) as usize;
// The tracker's lookahead: two periods of 30 Hz and a margin, about 67 ms.
assert!((lookahead as f32 / RATE - 0.067).abs() < 0.005);
let input: Vec<f32> = (0..2 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
let out = run(&mut e, &input);
for i in latency..input.len() {
assert_eq!(out[i], input[i - latency]);
}
}
#[test]
fn settings_are_validated_and_serialise() {
assert!(Settings::default().validate().is_ok());
for bad in [
Settings {
target: 3.,
..Settings::default()
},
Settings {
range: 30.,
..Settings::default()
},
Settings {
from: 10.,
..Settings::default()
},
Settings {
amount: f32::NAN,
..Settings::default()
},
] {
assert!(bad.validate().is_err());
assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err());
}
let s = Settings {
segment: Segment::Grid,
grid: Grid::Eighth,
source: Source::Midi,
target: -9.,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"target":0,"oops":1}"#).is_err());
}
+86
View File
@@ -0,0 +1,86 @@
use ballast_core::{Engine, Grid, Segment, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<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_transport_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 512 == 0 {
let playing = (i / 24000) % 2 == 0;
engine.set_transport(
Some(140.),
playing.then_some(i as f64 / 48000. * 140. / 60.),
);
}
if i % 4800 == 0 {
let step = i / 4800;
settings.segment = if step % 2 == 0 {
Segment::Grid
} else {
Segment::Notes
};
settings.grid = Grid::ALL[step % Grid::ALL.len()];
settings.source = if step % 4 < 2 {
Source::Audio
} else {
Source::Midi
};
settings.from = 20. + 30. * (step % 5) as f32;
settings.bypass = step % 7 == 6;
engine.set_settings(settings).unwrap();
engine.note_on(33 + (step % 12) as u8);
if step % 3 == 0 {
engine.learn();
}
}
if i % 4800 == 2400 {
engine.note_off(33 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, -x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "ballast"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
ballast-core = { path = "../core" }
ballast-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+210
View File
@@ -0,0 +1,210 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use ballast_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BallastParams, FLOATS};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
/// Set by the editor's Learn button, taken by the audio thread.
learn: AtomicBool,
}
struct EditorData {
ui: ballast_ui::State,
gestures: [bool; FLOATS],
}
pub struct Ballast {
params: Arc<BallastParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Ballast {
fn default() -> Self {
Self {
params: Arc::new(BallastParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
learn: AtomicBool::new(false),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Ballast {
const NAME: &'static str = "Ballast";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: ballast_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| ballast_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = shared.analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("ballast-editor")
.min_size(egui::vec2(
ballast_ui::WIDTH as f32 - 80.,
ballast_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
ballast_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
if std::mem::take(&mut data.ui.learn_request) {
shared.learn.store(true, Ordering::Release);
}
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<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);
if self.shared.learn.swap(false, Ordering::AcqRel) {
engine.learn();
}
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
let _ = self.shared.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Ballast {
const CLAP_ID: &'static str = "io.narl.bass-foundry.ballast";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Levels every note or switch-up to one loudness with a single gain each");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Utility,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Ballast);
+203
View File
@@ -0,0 +1,203 @@
use ballast_core::{
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SegmentParam {
#[id = "notes"]
Notes,
#[id = "grid"]
Grid,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GridParam {
#[id = "bar"]
#[name = "1 bar"]
Bar,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
}
const GRIDS: [(GridParam, Grid); 5] = [
(GridParam::Bar, Grid::Bar),
(GridParam::Half, Grid::Half),
(GridParam::Quarter, Grid::Quarter),
(GridParam::Eighth, Grid::Eighth),
(GridParam::Sixteenth, Grid::Sixteenth),
];
fn grid_param(g: Grid) -> GridParam {
GRIDS
.iter()
.find(|(_, v)| *v == g)
.map_or(GridParam::Quarter, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BallastParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "segment"]
pub segment: EnumParam<SegmentParam>,
#[id = "grid"]
pub grid: EnumParam<GridParam>,
#[id = "target"]
pub target: FloatParam,
#[id = "amount"]
pub amount: FloatParam,
#[id = "range"]
pub range: FloatParam,
#[id = "from"]
pub from: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BallastParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT),
source: EnumParam::new("Note source", SourceParam::Audio),
segment: EnumParam::new("Parts from", SegmentParam::Notes),
grid: EnumParam::new("Grid", grid_param(d.grid)),
target: linear("Target", d.target, TARGET_MIN, TARGET_MAX, " LUFS"),
amount: linear("Amount", d.amount, 0., 100., " %"),
range: linear("Range", d.range, 0., RANGE_MAX, " dB"),
from: FloatParam::new(
"Measure from",
d.from,
FloatRange::Skewed {
min: FROM_MIN,
max: FROM_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BallastParams {
pub fn settings(&self) -> Settings {
let grid = self.grid.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
segment: match self.segment.value() {
SegmentParam::Notes => Segment::Notes,
SegmentParam::Grid => Segment::Grid,
},
grid: GRIDS
.iter()
.find(|(p, _)| *p == grid)
.map_or(Grid::Quarter, |(_, g)| *g),
target: self.target.value(),
amount: self.amount.value(),
range: self.range.value(),
from: self.from.value(),
gate: self.gate.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.target,
&self.amount,
&self.range,
&self.from,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.target, s.amount, s.range, s.from, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.bypass != old.bypass {
setter.begin_set_parameter(&self.bypass);
setter.set_parameter(&self.bypass, new.bypass);
setter.end_set_parameter(&self.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
if new.segment != old.segment {
let v = match new.segment {
Segment::Notes => SegmentParam::Notes,
Segment::Grid => SegmentParam::Grid,
};
setter.begin_set_parameter(&self.segment);
setter.set_parameter(&self.segment, v);
setter.end_set_parameter(&self.segment);
}
if new.grid != old.grid {
setter.begin_set_parameter(&self.grid);
setter.set_parameter(&self.grid, grid_param(new.grid));
setter.end_set_parameter(&self.grid);
}
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<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 = &ballast::clap_entry;
assert!(entry.init.unwrap()(c"ballast.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Ballast");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.ballast"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<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;
// Amount 0 passes the input through the lookahead delay unchanged.
envelope.params.insert("amount".into(), ParamValue::F32(0.));
envelope
.params
.insert("target".into(), ParamValue::F32(-20.));
envelope.params.insert("from".into(), ParamValue::F32(100.));
envelope
.params
.insert("segment".into(), ParamValue::String("grid".into()));
envelope
.params
.insert("grid".into(), ParamValue::String("1-8".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<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;
// The tracker's 67 ms, the 100 ms measuring window and the 5 ms ramp.
assert!((delay as f64 / rate - 0.172).abs() < 0.005, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone at amount 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "ballast-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
ballast-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+376
View File
@@ -0,0 +1,376 @@
//! Ballast editor, shared by the CLAP wrapper and the headless renderer.
use ballast_core::{
AnalyzerFrame, Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, HISTORY, RANGE_MAX,
TARGET_MAX, TARGET_MIN,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 660;
pub struct State {
pub analyzer: AnalyzerFrame,
/// Set by the Learn button; the wrapper passes it to the engine and clears it.
pub learn_request: bool,
/// The last Learn result already applied. None until the first frame arrives, so an old
/// result is not applied again when the editor opens.
learn_seen: Option<u32>,
learned: Option<f32>,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
learn_request: false,
learn_seen: None,
learned: None,
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str = "Put Ballast on the bass after its sound design. Press LEARN on the \
switch-up that sits right, or set Target.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
match self.learn_seen {
Some(id) if id != frame.learn_id && frame.learned.is_finite() => {
self.learned = Some(frame.learned);
}
_ => {}
}
self.learn_seen = Some(frame.learn_id);
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 72., ACCENT);
hint(&r, state, help);
});
}
const LOW: f32 = -42.;
const HIGH: f32 = 0.;
/// The last parts: their loudness in (bars), after Ballast (marks) and the target.
fn parts(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
format!("LAST {HISTORY} PARTS · LOUDNESS OF THE FIRST 100 MS"),
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
"bar: in · mark: out",
FontId::proportional(11.),
MUTED,
);
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(44., 30.),
rect.right_bottom() - Vec2::new(12., 12.),
);
let y = |lufs: f32| {
let t = ((lufs - LOW) / (HIGH - LOW)).clamp(0., 1.);
area.bottom() - t * area.height()
};
for db in [-36., -24., -12., 0.] {
p.line_segment(
[
Pos2::new(area.left(), y(db)),
Pos2::new(area.right(), y(db)),
],
Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)),
);
p.text(
Pos2::new(area.left() - 6., y(db)),
egui::Align2::RIGHT_CENTER,
format!("{db:.0}"),
FontId::monospace(10.),
DIM,
);
}
let column = area.width() / HISTORY as f32;
// Newest part on the right.
let offset = HISTORY - frame.count;
for i in 0..frame.count {
let part = frame.history[i];
let x = area.left() + column * (offset + i) as f32;
let bar = Rect::from_min_max(
Pos2::new(x + column * 0.2, y(part.loudness)),
Pos2::new(x + column * 0.8, area.bottom()),
);
let color = if part.gated {
DIM.gamma_multiply(0.6)
} else {
STEEL.gamma_multiply(0.45)
};
p.rect_filled(bar, 1., color);
if !part.gated {
let out = y(part.loudness + part.gain);
p.line_segment(
[
Pos2::new(x + column * 0.1, out),
Pos2::new(x + column * 0.9, out),
],
Stroke::new(3.0_f32, ACCENT),
);
}
}
let t = y(settings.target);
p.line_segment(
[Pos2::new(area.left(), t), Pos2::new(area.right(), t)],
Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.5)),
);
p.text(
Pos2::new(area.left() + 4., t - 3.),
egui::Align2::LEFT_BOTTOM,
format!("TARGET {:.1} LUFS", settings.target),
FontId::proportional(10.),
ACCENT,
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
if let Some(l) = state.learned.take() {
settings.target = l.clamp(TARGET_MIN, TARGET_MAX);
}
ui.add_space(2.);
header(ui, "BALLAST", "SWITCH-UP LEVELLER", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
if settings.segment == Segment::Notes {
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r =
ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "NOTES FROM");
ui.separator();
}
for (segment, label) in [(Segment::Grid, "GRID"), (Segment::Notes, "NOTES")] {
let r =
ui.selectable_label(settings.segment == segment, RichText::new(label).size(13.));
if r.clicked() {
settings.segment = segment;
}
}
caption(ui, "PARTS FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(250.);
ui.set_min_height(TOP);
ui.vertical(|ui| readings(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
parts(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 300.;
fn readings(ui: &mut egui::Ui, settings: &Settings, state: &mut State, frame: &AnalyzerFrame) {
caption(ui, "SPREAD OF THE LAST PARTS");
let known = frame.spread_in.is_finite();
ui.label(
RichText::new(if known {
format!("{:.1} · {:.1} dB", frame.spread_in, frame.spread_out)
} else {
"—".into()
})
.font(FontId::new(34., egui::FontFamily::Name("medium".into())))
.color(if known { TEXT } else { DIM }),
);
ui.label(
RichText::new("in · out, standard deviation")
.size(12.)
.color(MUTED),
);
ui.add_space(12.);
readout(ui, "GAIN NOW", format!("{:+.1} dB", frame.gain), ACCENT);
readout(ui, "TARGET", format!("{:.1} LUFS", settings.target), TEXT);
let label = if frame.learning || state.learn_request {
"LISTENING"
} else {
"LEARN"
};
let r = ui.selectable_label(
frame.learning || state.learn_request,
RichText::new(label).size(13.),
);
if r.clicked() {
state.learn_request = true;
}
hint(
&r,
state,
"Takes the loudness of the next part over the gate as the target. Press it just \
before the switch-up that sits right in the mix.",
);
ui.add_space(10.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate.max(1.) * 1000.,
match settings.segment {
Segment::Notes => match settings.source {
Source::Audio => "parts at detected notes".to_string(),
Source::Midi => "parts at MIDI notes".to_string(),
},
Segment::Grid => format!(
"grid at {:.1} BPM, {}",
frame.tempo,
if frame.synced {
"host transport"
} else {
"free running"
}
),
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
if settings.segment == Segment::Grid {
ui.horizontal(|ui| {
caption(ui, "ONE PART EVERY");
ui.add_space(6.);
for grid in Grid::ALL {
let r = ui
.selectable_label(settings.grid == grid, RichText::new(grid.label()).size(13.));
if r.clicked() {
settings.grid = grid;
}
hint(
&r,
state,
"A new part starts on every line of this grid, also when the note does not \
change.",
);
}
});
ui.add_space(8.);
ui.separator();
}
ui.columns(6, |c| {
knob(
&mut c[0],
state,
"Target",
&mut settings.target,
TARGET_MIN..=TARGET_MAX,
d.target,
" LUFS",
"Loudness every part lands at, over its first 100 ms.",
);
knob(
&mut c[1],
state,
"Amount",
&mut settings.amount,
0. ..=100.,
d.amount,
" %",
"Share of the correction. 50 % halves the differences between parts.",
);
knob(
&mut c[2],
state,
"Range",
&mut settings.range,
0. ..=RANGE_MAX,
d.range,
" dB",
"Most boost or cut a part gets.",
);
knob(
&mut c[3],
state,
"Measure from",
&mut settings.from,
FROM_MIN..=FROM_MAX,
d.from,
" Hz",
"The measurement hears only what is above this. Set it to Anvil's Split when \
Anvil makes the sub; the gain still applies to the whole signal.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Note-start threshold, and the loudness under which a part keeps the last gain.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}
+57
View File
@@ -165,5 +165,62 @@
"braze-core",
"braze-ui"
]
},
"quench": {
"name": "Quench",
"description": "Clips the top as hard as you like while the sub passes clean",
"manifest": "plugins/quench/plugin/Cargo.toml",
"library": "quench",
"documentation": "docs/quench",
"clap_id": "io.narl.bass-foundry.quench",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"quench-core",
"quench-ui"
]
},
"ballast": {
"name": "Ballast",
"description": "Levels every note or switch-up to one loudness with a single gain each",
"manifest": "plugins/ballast/plugin/Cargo.toml",
"library": "ballast",
"documentation": "docs/ballast",
"clap_id": "io.narl.bass-foundry.ballast",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"ballast-core",
"ballast-ui"
]
},
"forge": {
"name": "Forge",
"description": "One tracked channel strip: Lathe, Temper and Knurl behind one note tracker, in any order",
"manifest": "plugins/forge/plugin/Cargo.toml",
"library": "forge",
"documentation": "docs/forge",
"clap_id": "io.narl.bass-foundry.forge",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"forge-core",
"forge-ui",
"lathe-core",
"lathe-ui",
"temper-core",
"temper-ui",
"knurl-core",
"knurl-ui"
]
}
}
+1
View File
@@ -1,4 +1,5 @@
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
pub mod loudness;
pub mod meter;
pub mod oversample;
pub mod svf;
+71
View File
@@ -0,0 +1,71 @@
//! ITU-R BS.1770 K-weighting: the pre-filter (a high shelf for the head) and the RLB high-pass.
//! Mean square of the weighted signal, summed over channels, gives the loudness in LUFS as
//! −0.691 + 10·log10(sum). Coefficients come from the standard's analog prototypes, so any
//! sample rate is exact, not only 48 kHz.
/// Biquad in transposed direct form II, in f64: the RLB pole sits close to 1 at high rates.
#[derive(Clone, Copy, Debug, Default)]
struct Stage {
b: [f64; 3],
a: [f64; 2],
z: [f64; 2],
}
impl Stage {
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b[0] * x + self.z[0];
self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
self.z[1] = self.b[2] * x - self.a[1] * y;
y
}
}
/// One channel of K-weighting. Allocation-free.
#[derive(Clone, Copy, Debug)]
pub struct KWeighting {
shelf: Stage,
high: Stage,
}
impl KWeighting {
pub fn new(rate: f32) -> Self {
let rate = rate as f64;
// Pre-filter: high shelf of about +4 dB above 1.7 kHz.
let (f0, g, q) = (1681.974450955533, 3.999843853973347, 0.7071752369554196);
let k = (std::f64::consts::PI * f0 / rate).tan();
let vh = 10f64.powf(g / 20.);
let vb = vh.powf(0.4996667741545416);
let a0 = 1. + k / q + k * k;
let shelf = Stage {
b: [
(vh + vb * k / q + k * k) / a0,
2. * (k * k - vh) / a0,
(vh - vb * k / q + k * k) / a0,
],
a: [2. * (k * k - 1.) / a0, (1. - k / q + k * k) / a0],
z: [0.; 2],
};
// RLB: second-order high-pass at 38 Hz.
let (f0, q) = (38.13547087602444, 0.5003270373238773);
let k = (std::f64::consts::PI * f0 / rate).tan();
let a0 = 1. + k / q + k * k;
let high = Stage {
b: [1., -2., 1.],
a: [2. * (k * k - 1.) / a0, (1. - k / q + k * k) / a0],
z: [0.; 2],
};
Self { shelf, high }
}
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
self.high.process(self.shelf.process(x as f64)) as f32
}
pub fn reset(&mut self) {
self.shelf.z = [0.; 2];
self.high.z = [0.; 2];
}
}
/// Loudness in LUFS of a mean square summed over channels.
pub fn lufs(mean_square: f64) -> f32 {
(-0.691 + 10. * mean_square.max(1e-20).log10()) as f32
}
+52
View File
@@ -397,3 +397,55 @@ impl PitchFollower {
self.hz
}
}
/// A processor driven by note events that are aligned with its input audio. Lathe, Temper and
/// Knurl are built from one each, so the same code runs standalone (after its own tracker) and
/// inside a strip that shares one tracker between several of them.
pub trait Tracked {
/// Delay of the output behind the input, in samples.
fn latency(&self) -> usize;
/// The undelayed input, one tracker lookahead ahead of the audio passed to `process`.
/// Called before the sample's events.
fn ahead(&mut self, _mono: f32) {}
/// A note event due at the next `process` sample.
fn event(&mut self, e: NoteEvent);
/// One sample of audio; `sounding` is the tracker's note state at this sample.
fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2];
}
/// A tracker and the lookahead delay in front of one [`Tracked`] module: the standalone form.
pub struct Lookahead {
pub tracker: Tracker,
delay: Vec<[f32; 2]>,
at: usize,
}
impl Lookahead {
pub fn new(rate: f32, cfg: TrackerConfig) -> Self {
let tracker = Tracker::new(rate, cfg);
let len = tracker.latency() + 1;
Self {
tracker,
delay: vec![[0.; 2]; len],
at: 0,
}
}
/// The tracker's lookahead, in samples.
pub fn latency(&self) -> usize {
self.tracker.latency()
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
}
/// Feeds one input sample (`mono` is its channel average) and returns `module`'s output.
#[inline]
pub fn process(&mut self, x: [f32; 2], mono: f32, module: &mut impl Tracked) -> [f32; 2] {
module.ahead(mono);
self.tracker.process(mono, |e| module.event(e));
let len = self.delay.len();
self.delay[self.at] = x;
self.at = (self.at + 1) % len;
let y = self.delay[self.at];
module.process(y, self.tracker.sounding())
}
}
+26
View File
@@ -80,3 +80,29 @@ fn wave_round_trip_keeps_root_and_wavetable_chunks() {
assert_eq!(wav::decode(&bytes).unwrap(), w);
assert!(wav::decode(&bytes[..40]).is_err());
}
#[test]
fn k_weighting_reads_a_full_scale_997_hz_sine_at_minus_3_lufs() {
use common_dsp::loudness::{lufs, KWeighting};
let level = |rate: f32, hz: f64| {
let mut k = KWeighting::new(rate);
let n = rate as usize;
let mut sum = 0f64;
for i in 0..2 * n {
let x = (std::f64::consts::TAU * hz * i as f64 / rate as f64).sin() as f32;
let y = k.process(x) as f64;
if i >= n {
sum += y * y;
}
}
lufs(sum / n as f64)
};
// BS.1770-4: a 0 dBFS 997 Hz sine in one channel reads −3.01 LKFS, at every rate.
for rate in [44100., 48000., 96000., 192000.] {
let got = level(rate, 997.);
// BS.1770 allows meters 0.1 LU; the prototypes land within 0.03 up to 192 kHz.
assert!((got + 3.01).abs() < 0.05, "{rate}: {got}");
}
// The RLB high-pass counts a 30 Hz sub much less than the mids.
assert!(level(48000., 30.) < level(48000., 997.) - 5.);
}
+52 -11
View File
@@ -113,6 +113,58 @@ pub fn rotary(
unit: &str,
size: f32,
color: Color32,
) -> egui::Response {
let response = dial(ui, value, range.clone(), default, size, color);
dial_caption(ui, name, size);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// A rotary whose value field shows custom text, for values that are not a plain number
/// with a unit (a ratio, a mapped knob position).
#[allow(clippy::too_many_arguments)]
pub fn rotary_text(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
size: f32,
color: Color32,
format: impl Fn(f32) -> String,
parse: impl Fn(&str) -> Option<f32>,
) -> egui::Response {
let response = dial(ui, value, range.clone(), default, size, color);
dial_caption(ui, name, size);
let speed = (*range.end() - *range.start()) as f64 / 200.;
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(speed)
.custom_formatter(|v, _| format(v as f32))
.custom_parser(|s| parse(s).map(f64::from)),
);
response
}
fn dial_caption(ui: &mut egui::Ui, name: &str, size: f32) {
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
}
/// The knob itself: drag, keys, double-click reset, and drawing.
fn dial(
ui: &mut egui::Ui,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
size: f32,
color: Color32,
) -> egui::Response {
let low = *range.start();
let high = *range.end();
@@ -212,17 +264,6 @@ pub fn rotary(
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
}
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// Vertical gradient rectangle, used for panel faces.
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "forge-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
lathe-core = { path = "../../lathe/core" }
temper-core = { path = "../../temper/core" }
knurl-core = { path = "../../knurl/core" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+271
View File
@@ -0,0 +1,271 @@
//! Forge: one tracked channel strip. One note tracker reads the strip's input, and Lathe,
//! Temper and Knurl run behind it as modules in a chosen order.
//!
//! The audio path is delayed by the tracker's lookahead once. Every module gets the note events
//! delayed by the latency of the modules in front of it, so each module sees a note start on
//! that note's attack in the audio it receives. A module that is switched off still delays
//! its audio by its own latency, so the strip's latency never changes. Allocation-free after
//! `new`.
mod settings;
use common_dsp::tracker::{NoteEvent, Tracked, Tracker, TrackerConfig};
use common_dsp::{coefficient, hz_to_midi};
pub use settings::{Settings, Source, Stage, ORDERS, STAGES};
/// Note events kept per sample. More arrive only on floods; the oldest are dropped.
const EVENTS: usize = 4;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
pub lathe: lathe_core::AnalyzerFrame,
pub temper: temper_core::AnalyzerFrame,
pub knurl: knurl_core::AnalyzerFrame,
/// Pitch at the strip's output, 0 before the first note.
pub hz: f32,
pub note: Option<u8>,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
lathe: lathe_core::AnalyzerFrame::default(),
temper: temper_core::AnalyzerFrame::default(),
knurl: knurl_core::AnalyzerFrame::default(),
hz: 0.,
note: None,
latency: 0,
sample_rate: 48000.,
}
}
}
/// What the tracker reported for one sample, kept so later modules can replay it.
#[derive(Clone, Copy)]
struct Slot {
events: [NoteEvent; EVENTS],
len: usize,
sounding: bool,
/// The undelayed input, one lookahead ahead of the audio this slot belongs to.
ahead: f32,
}
impl Default for Slot {
fn default() -> Self {
Self {
events: [NoteEvent::Stop; EVENTS],
len: 0,
sounding: false,
ahead: 0.,
}
}
}
impl Slot {
fn push(&mut self, e: NoteEvent) {
if self.len == EVENTS {
self.events.copy_within(1.., 0);
self.len -= 1;
}
self.events[self.len] = e;
self.len += 1;
}
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
tracker: Tracker,
delay: Vec<[f32; 2]>,
delay_at: usize,
lathe: lathe_core::Module,
temper: temper_core::Module,
knurl: knurl_core::Module,
/// Tracker reports of the last samples, newest at `line_at`.
line: Vec<Slot>,
line_at: usize,
/// How far back in `line` each position of the order reads.
offsets: [usize; STAGES],
/// The aligned input, delayed by every module's latency, for the strip's bypass.
dry: Vec<[f32; 2]>,
dry_at: usize,
/// Pitch at the output, for the editor.
pitch: common_dsp::tracker::PitchFollower,
sounding: bool,
smooth: f32,
bypass: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
let lathe = lathe_core::Module::new(rate, channels)?;
let temper = temper_core::Module::new(rate, channels)?;
let knurl = knurl_core::Module::new(rate, channels)?;
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let modules = lathe.latency() + temper.latency() + knurl.latency();
let lookahead = tracker.latency();
let mut e = Self {
rate,
channels,
settings: Settings::default(),
tracker,
delay: vec![[0.; 2]; lookahead + 1],
delay_at: 0,
lathe,
temper,
knurl,
line: vec![Slot::default(); modules + 1],
line_at: 0,
offsets: [0; STAGES],
dry: vec![[0.; 2]; modules + 1],
dry_at: 0,
pitch: Default::default(),
sounding: false,
smooth: coefficient(rate, 10.),
bypass: 0.,
fresh: true,
};
// The modules already hold their defaults; only the routing still needs them.
e.route(&Settings::default());
Ok(e)
}
/// The tracker's lookahead plus every module's latency, in any order.
pub fn latency_samples(&self) -> u32 {
(self.tracker.latency() + self.dry.len() - 1) 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()?;
let mut s = s;
s.share();
self.lathe.set_settings(s.lathe)?;
self.temper.set_settings(s.temper)?;
self.knurl.set_settings(s.knurl)?;
self.route(&s);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Tracker settings and how far back each position reads the note events.
fn route(&mut self, s: &Settings) {
// Each module decides itself whether a legato note slides.
self.tracker.set(s.source == Source::Midi, s.gate, true);
let mut at = 0;
for (i, stage) in s.order.into_iter().enumerate() {
self.offsets[i] = at;
at += self.module(stage).latency();
}
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
self.line.fill(Slot::default());
self.dry.fill([0.; 2]);
self.lathe.reset();
self.temper.reset();
self.knurl.reset();
self.pitch = Default::default();
self.sounding = false;
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);
}
fn module(&mut self, stage: Stage) -> &mut dyn Tracked {
match stage {
Stage::Lathe => &mut self.lathe,
Stage::Temper => &mut self.temper,
Stage::Knurl => &mut self.knurl,
}
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let mut slot = Slot {
ahead: mono,
..Slot::default()
};
self.tracker.process(mono, |e| slot.push(e));
slot.sounding = self.tracker.sounding();
let len = self.line.len();
self.line_at = (self.line_at + 1) % len;
self.line[self.line_at] = slot;
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 mut v = y;
let len = self.line.len();
for i in 0..STAGES {
let slot = self.line[(self.line_at + len - self.offsets[i]) % len];
let m: &mut dyn Tracked = match self.settings.order[i] {
Stage::Lathe => &mut self.lathe,
Stage::Temper => &mut self.temper,
Stage::Knurl => &mut self.knurl,
};
m.ahead(slot.ahead);
for &e in &slot.events[..slot.len] {
m.event(e);
}
v = m.process(v, slot.sounding);
}
// The pitch at the output: the oldest slot is the last module's input time minus its
// latency, which is the output time.
let last = self.line[(self.line_at + 1) % self.line.len()];
for &e in &last.events[..last.len] {
self.pitch.event(false, e);
}
self.pitch.step(1.);
self.sounding = last.sounding;
let len = self.dry.len();
self.dry_at = (self.dry_at + 1) % len;
self.dry[self.dry_at] = y;
let dry = self.dry[(self.dry_at + 1) % len];
let s = &self.settings;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = v[c] * (1. - self.bypass) + dry[c] * self.bypass;
}
out
}
/// Editor data of every module, with the strip's latency.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let latency = self.latency_samples();
let hz = self.pitch.hz();
AnalyzerFrame {
lathe: lathe_core::AnalyzerFrame {
latency,
..self.lathe.take_analyzer()
},
temper: temper_core::AnalyzerFrame {
latency,
..self.temper.take_analyzer()
},
knurl: knurl_core::AnalyzerFrame {
latency,
..self.knurl.take_analyzer()
},
hz,
note: (self.sounding && hz > 0.).then(|| hz_to_midi(hz).round().clamp(0., 127.) as u8),
latency,
sample_rate: self.rate,
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Host-independent controls of the strip and its modules.
use serde::{Deserialize, Serialize};
#[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,
}
/// One module of the strip.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum Stage {
Lathe,
Temper,
Knurl,
}
impl Stage {
pub const ALL: [Stage; 3] = [Stage::Lathe, Stage::Temper, Stage::Knurl];
pub fn name(self) -> &'static str {
match self {
Stage::Lathe => "Lathe",
Stage::Temper => "Temper",
Stage::Knurl => "Knurl",
}
}
}
pub const STAGES: usize = Stage::ALL.len();
/// Every order of the three modules. The first is the default: harmonic EQ, then distortion,
/// then the cycle texture, as in the standalone chain.
pub const ORDERS: [[Stage; STAGES]; 6] = [
[Stage::Lathe, Stage::Temper, Stage::Knurl],
[Stage::Temper, Stage::Lathe, Stage::Knurl],
[Stage::Lathe, Stage::Knurl, Stage::Temper],
[Stage::Temper, Stage::Knurl, Stage::Lathe],
[Stage::Knurl, Stage::Lathe, Stage::Temper],
[Stage::Knurl, Stage::Temper, Stage::Lathe],
];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
/// Pitch source of the one tracker every module follows.
pub source: Source,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Modules in processing order.
pub order: [Stage; STAGES],
/// Module settings. Their `source` and `gate` are replaced by the strip's, and their
/// `bypass` switches the module off.
pub lathe: lathe_core::Settings,
pub temper: temper_core::Settings,
pub knurl: knurl_core::Settings,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
gate: lathe_core::Settings::default().gate,
order: ORDERS[0],
lathe: lathe_core::Settings::default(),
temper: temper_core::Settings::default(),
knurl: knurl_core::Settings::default(),
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let ok = (-60.0..=-20.).contains(&self.gate) && ORDERS.contains(&self.order);
if !ok {
return Err("invalid settings");
}
self.lathe.validate()?;
self.temper.validate()?;
self.knurl.validate()
}
/// True when the module is switched on.
pub fn on(&self, stage: Stage) -> bool {
!match stage {
Stage::Lathe => self.lathe.bypass,
Stage::Temper => self.temper.bypass,
Stage::Knurl => self.knurl.bypass,
}
}
pub fn set_on(&mut self, stage: Stage, on: bool) {
match stage {
Stage::Lathe => self.lathe.bypass = !on,
Stage::Temper => self.temper.bypass = !on,
Stage::Knurl => self.knurl.bypass = !on,
}
}
/// Copies the strip's pitch source and gate into every module.
pub fn share(&mut self) {
let midi = self.source == Source::Midi;
self.lathe.gate = self.gate;
self.temper.gate = self.gate;
self.knurl.gate = self.gate;
self.lathe.source = if midi {
lathe_core::Source::Midi
} else {
lathe_core::Source::Audio
};
self.temper.source = if midi {
temper_core::Source::Midi
} else {
temper_core::Source::Audio
};
self.knurl.source = if midi {
knurl_core::Source::Midi
} else {
knurl_core::Source::Audio
};
}
}
+219
View File
@@ -0,0 +1,219 @@
use forge_core::{Engine, Settings, Source, Stage, ORDERS};
const RATE: f32 = 48000.;
/// A bassline of saw notes with gaps, a different pitch each, at A1 to A2.
fn bass(i: usize) -> [f32; 2] {
let note = [33., 40., 36., 45., 38.][(i / 14400) % 5];
let hz = 440. * 2f64.powf((note - 69.) / 12.);
let t = i as f64 / RATE as f64;
let on = i % 14400 < 12000;
let v = if on {
(0.4 * (2. * (hz * t).fract() - 1.)) as f32
} else {
0.
};
[v, 0.8 * v]
}
/// Module settings that change the sound, so a mismatch would show.
fn shaped() -> Settings {
let mut s = Settings::default();
s.lathe.gains[2] = -12.;
s.lathe.tilt = -3.;
s.temper.drive = 12.;
s.temper.bias = 20.;
s.temper.push_gain = 6.;
s.temper.bright = 8.;
s.knurl.apply_pattern(&[0., -9.]);
s.knurl.time_jitter = 6.;
s.knurl.level_jitter = 2.;
s.knurl.grit = -18.;
s
}
fn only(stage: Stage, order: [Stage; 3]) -> Settings {
let mut s = shaped();
s.order = order;
for st in Stage::ALL {
s.set_on(st, st == stage);
}
s
}
/// One module on its own in the strip sounds exactly like its standalone plugin, in every
/// position: the strip's tracker and note timing are the plugin's.
#[test]
fn each_module_alone_matches_its_standalone_plugin() {
let n = 80000;
for order in ORDERS {
for stage in Stage::ALL {
let s = only(stage, order);
let mut forge = Engine::new(RATE, 2).unwrap();
forge.set_settings(s).unwrap();
let (alone, latency): (Vec<[f32; 2]>, u32) = match stage {
Stage::Lathe => {
let mut e = lathe_core::Engine::new(RATE, 2).unwrap();
e.set_settings(s.lathe).unwrap();
(
(0..n).map(|i| e.process_sample(bass(i))).collect(),
e.latency_samples(),
)
}
Stage::Temper => {
let mut e = temper_core::Engine::new(RATE, 2).unwrap();
e.set_settings(s.temper).unwrap();
(
(0..n).map(|i| e.process_sample(bass(i))).collect(),
e.latency_samples(),
)
}
Stage::Knurl => {
let mut e = knurl_core::Engine::new(RATE, 2).unwrap();
e.set_settings(s.knurl).unwrap();
(
(0..n).map(|i| e.process_sample(bass(i))).collect(),
e.latency_samples(),
)
}
};
let strip: Vec<[f32; 2]> = (0..n).map(|i| forge.process_sample(bass(i))).collect();
let shift = (forge.latency_samples() - latency) as usize;
for i in 0..n - shift {
assert_eq!(
strip[i + shift],
alone[i],
"{stage:?} in {order:?}, sample {i}"
);
}
}
}
}
#[test]
fn the_strip_has_one_lookahead() {
let forge = Engine::new(RATE, 2).unwrap();
let lathe = lathe_core::Engine::new(RATE, 2).unwrap().latency_samples();
let temper = temper_core::Engine::new(RATE, 2).unwrap().latency_samples();
let knurl = knurl_core::Engine::new(RATE, 2).unwrap().latency_samples();
// Lathe has no latency of its own: its latency is the lookahead.
let own = (temper - lathe) + (knurl - lathe);
assert_eq!(forge.latency_samples(), lathe + own);
let ms = |s: u32| s as f32 / RATE * 1000.;
assert!(
ms(forge.latency_samples()) < 75.,
"{}",
ms(forge.latency_samples())
);
assert!(ms(lathe + temper + knurl) > 200.);
// Changing the order keeps the latency.
let mut f = Engine::new(RATE, 2).unwrap();
let before = f.latency_samples();
for order in ORDERS {
f.set_settings(Settings {
order,
..Settings::default()
})
.unwrap();
assert_eq!(f.latency_samples(), before);
}
}
/// Knurl's sub-octave pattern first, then Lathe: Lathe still follows the played note, because
/// the tracker reads the strip's input. A standalone Lathe after Knurl hears the sub-octave.
#[test]
fn modules_after_knurl_follow_the_played_note() {
let mut s = Settings {
order: [Stage::Knurl, Stage::Lathe, Stage::Temper],
..Settings::default()
};
s.knurl.apply_pattern(&[0., -12.]);
s.temper.bypass = true;
let mut forge = Engine::new(RATE, 2).unwrap();
forge.set_settings(s).unwrap();
let mut knurl = knurl_core::Engine::new(RATE, 2).unwrap();
knurl.set_settings(s.knurl).unwrap();
let mut lathe = lathe_core::Engine::new(RATE, 2).unwrap();
lathe.set_settings(s.lathe).unwrap();
// One long note at A2 (110 Hz).
let hz = 110.;
let note = |i: usize| {
let v = (0.4 * (2. * (hz * i as f64 / RATE as f64).fract() - 1.)) as f32;
[v, v]
};
let (mut strip_ok, mut chain_ok, mut frames) = (0, 0, 0);
for i in 0..96000 {
forge.process_sample(note(i));
lathe.process_sample(knurl.process_sample(note(i)));
if i > 24000 && i % 480 == 0 {
frames += 1;
let near = |f: f32| (f / hz as f32 - 1.).abs() < 0.02;
strip_ok += usize::from(near(forge.take_analyzer().lathe.hz));
chain_ok += usize::from(near(lathe.take_analyzer().hz));
}
}
assert_eq!(strip_ok, frames);
assert!(chain_ok < frames / 2, "{chain_ok} of {frames}");
}
#[test]
fn bypass_is_the_input_delayed_by_the_latency() {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
bypass: true,
..shaped()
})
.unwrap();
let latency = e.latency_samples() as usize;
let out: Vec<[f32; 2]> = (0..30000).map(|i| e.process_sample(bass(i))).collect();
for (i, o) in out.iter().enumerate().skip(latency) {
assert_eq!(*o, bass(i - latency));
}
}
#[test]
fn midi_notes_drive_every_module() {
let mut s = shaped();
s.source = Source::Midi;
let mut e = Engine::new(RATE, 1).unwrap();
e.set_settings(s).unwrap();
for i in 0..24000 {
if i == 100 {
e.note_on(45);
}
e.process_sample(bass(i));
}
let f = e.take_analyzer();
assert_eq!(f.note, Some(45));
assert_eq!(f.lathe.note, Some(45));
assert_eq!(f.temper.note, Some(45));
assert_eq!(f.knurl.note, Some(45));
assert_eq!(f.lathe.latency, e.latency_samples());
}
#[test]
fn settings_are_validated_and_serialise() {
assert!(Settings::default().validate().is_ok());
let bad = Settings {
order: [Stage::Lathe, Stage::Lathe, Stage::Knurl],
..Settings::default()
};
assert!(bad.validate().is_err());
assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err());
let bad = Settings {
gate: -10.,
..Settings::default()
};
assert!(bad.validate().is_err());
let mut bad = Settings::default();
bad.temper.drive = f32::NAN;
assert!(bad.validate().is_err());
let s = Settings {
order: ORDERS[4],
source: Source::Midi,
..shaped()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"gate":-40,"oops":1}"#).is_err());
}
+72
View File
@@ -0,0 +1,72 @@
use forge_core::{Engine, Settings, Source, Stage, ORDERS};
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();
settings.knurl.grit = -12.;
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
let step = i / 4800;
settings.order = ORDERS[step % ORDERS.len()];
settings.source = if step % 4 < 2 {
Source::Audio
} else {
Source::Midi
};
settings.set_on(Stage::ALL[step % 3], step % 2 == 0);
settings.temper.drive = (step % 5) as f32 * 6.;
engine.set_settings(settings).unwrap();
engine.note_on(33 + (step % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(33 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, -x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2448
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "forge"
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" }
forge-core = { path = "../core" }
lathe-core = { path = "../../lathe/core" }
temper-core = { path = "../../temper/core" }
knurl-core = { path = "../../knurl/core" }
forge-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+201
View File
@@ -0,0 +1,201 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use forge_core::{AnalyzerFrame, Engine};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{Drawn, ForgeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: forge_ui::State,
gestures: [bool; FLOATS],
}
pub struct Forge {
params: Arc<ForgeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
drawn: Drawn,
publish_count: usize,
publish_interval: usize,
}
impl Default for Forge {
fn default() -> Self {
Self {
params: Arc::new(ForgeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
drawn: Drawn::default(),
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Forge {
fn settings(&mut self) -> forge_core::Settings {
let s = self.params.settings(&self.drawn);
self.drawn = Drawn {
gains: s.lathe.gains,
pattern: params::Pattern {
gains: s.knurl.pattern,
grit: s.knurl.grit_steps,
},
};
s
}
}
impl Plugin for Forge {
const NAME: &'static str = "Forge";
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: forge_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| forge_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings(&params.drawn());
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("forge-editor")
.min_size(egui::vec2(
forge_ui::WIDTH as f32 - 80.,
forge_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
forge_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Meters and scopes 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.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 settings = self.settings();
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
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 the module tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Forge {
const CLAP_ID: &'static str = "io.narl.bass-foundry.forge";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"One tracked channel strip: Lathe, Temper and Knurl behind one note tracker, in any order",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Equalizer,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Forge);
+605
View File
@@ -0,0 +1,605 @@
use forge_core::{Settings, Source, ORDERS};
use knurl_core::{Seed, GRIT_OFF, STEPS, STEP_MIN};
use lathe_core::HARMONICS;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
use temper_core::Curve;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
/// The six orders of `ORDERS`, in the same order.
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum OrderParam {
#[id = "lathe-temper-knurl"]
#[name = "Lathe, Temper, Knurl"]
LatheTemperKnurl,
#[id = "temper-lathe-knurl"]
#[name = "Temper, Lathe, Knurl"]
TemperLatheKnurl,
#[id = "lathe-knurl-temper"]
#[name = "Lathe, Knurl, Temper"]
LatheKnurlTemper,
#[id = "temper-knurl-lathe"]
#[name = "Temper, Knurl, Lathe"]
TemperKnurlLathe,
#[id = "knurl-lathe-temper"]
#[name = "Knurl, Lathe, Temper"]
KnurlLatheTemper,
#[id = "knurl-temper-lathe"]
#[name = "Knurl, Temper, Lathe"]
KnurlTemperLathe,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum CurveParam {
#[id = "soft"]
Soft,
#[id = "hard"]
Hard,
#[id = "fold"]
Fold,
#[id = "tube"]
Tube,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
#[id = "same"]
#[name = "Same every note"]
Same,
#[id = "free"]
Free,
}
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 on(name: &str) -> BoolParam {
BoolParam::new(format!("{name} on"), true)
}
/// Knurl's drawn pattern: step gains and grit switches.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pattern {
pub gains: [f32; STEPS],
pub grit: [bool; STEPS],
}
impl Default for Pattern {
fn default() -> Self {
let d = knurl_core::Settings::default();
Self {
gains: d.pattern,
grit: d.grit_steps,
}
}
}
/// Drawn data saved with the project, not exposed as automation lanes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Drawn {
pub gains: [f32; HARMONICS],
pub pattern: Pattern,
}
impl Default for Drawn {
fn default() -> Self {
Self {
gains: lathe_core::Settings::default().gains,
pattern: Pattern::default(),
}
}
}
#[derive(Params)]
pub struct LatheParams {
#[persist = "gains"]
pub gains: RwLock<[f32; HARMONICS]>,
#[id = "on"]
pub on: BoolParam,
#[id = "tilt"]
pub tilt: FloatParam,
#[id = "odd-even"]
pub odd_even: FloatParam,
#[id = "between"]
pub between: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "smooth"]
pub smooth: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "harmonics"]
pub harmonics: IntParam,
#[id = "snap"]
pub snap: BoolParam,
}
impl Default for LatheParams {
fn default() -> Self {
let d = lathe_core::Settings::default();
Self {
gains: RwLock::new(d.gains),
on: on("Lathe"),
tilt: linear("Lathe tilt", d.tilt, -12., 12., " dB/oct"),
odd_even: linear("Lathe odd/even", d.odd_even, -12., 12., " dB"),
between: linear("Lathe between", d.between, -24., 12., " dB"),
width: linear("Lathe width", d.width, 5., 50., " %"),
smooth: linear("Lathe smooth", d.smooth, 0., 100., " ms"),
mix: linear("Lathe mix", d.mix, 0., 100., " %"),
output: linear("Lathe output", d.output, -12., 12., " dB"),
harmonics: IntParam::new(
"Lathe harmonics",
d.harmonics as i32,
IntRange::Linear {
min: 8,
max: HARMONICS as i32,
},
),
snap: BoolParam::new("Lathe snap to semitone", d.snap),
}
}
}
#[derive(Params)]
pub struct TemperParams {
#[id = "on"]
pub on: BoolParam,
#[id = "curve"]
pub curve: EnumParam<CurveParam>,
#[id = "drive"]
pub drive: FloatParam,
#[id = "bias"]
pub bias: FloatParam,
#[id = "normalize"]
pub normalize: FloatParam,
#[id = "floor"]
pub floor: FloatParam,
#[id = "push"]
pub push_gain: FloatParam,
#[id = "bright"]
pub bright: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "push-at"]
pub push_harmonic: IntParam,
#[id = "auto-gain"]
pub auto_gain: BoolParam,
}
impl Default for TemperParams {
fn default() -> Self {
let d = temper_core::Settings::default();
Self {
on: on("Temper"),
curve: EnumParam::new("Temper curve", CurveParam::Soft),
drive: linear("Temper drive", d.drive, 0., 36., " dB"),
bias: linear("Temper bias", d.bias, 0., 100., " %"),
normalize: linear("Temper normalize", d.normalize, 0., 100., " %"),
floor: linear("Temper floor", d.floor, 0., 2., " ×f0"),
push_gain: linear("Temper push", d.push_gain, -12., 12., " dB"),
bright: linear("Temper bright", d.bright, 0., 64., " ×f0"),
mix: linear("Temper mix", d.mix, 0., 100., " %"),
output: linear("Temper output", d.output, -12., 12., " dB"),
push_harmonic: IntParam::new(
"Temper push at",
d.push_harmonic as i32,
IntRange::Linear { min: 1, max: 16 },
),
auto_gain: BoolParam::new("Temper auto gain", d.auto_gain),
}
}
}
#[derive(Params)]
pub struct KnurlParams {
#[persist = "pattern"]
pub pattern: RwLock<Pattern>,
#[id = "on"]
pub on: BoolParam,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
pub steps: IntParam,
#[id = "edge"]
pub edge: FloatParam,
#[id = "level-jitter"]
pub level_jitter: FloatParam,
#[id = "time-jitter"]
pub time_jitter: FloatParam,
#[id = "grit"]
pub grit: FloatParam,
#[id = "tone"]
pub tone: FloatParam,
#[id = "position"]
pub position: FloatParam,
#[id = "length"]
pub length: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
}
impl Default for KnurlParams {
fn default() -> Self {
let d = knurl_core::Settings::default();
Self {
pattern: RwLock::new(Pattern::default()),
on: on("Knurl"),
seed: EnumParam::new("Knurl random", SeedParam::Same),
steps: IntParam::new(
"Knurl steps",
d.steps as i32,
IntRange::Linear {
min: 1,
max: STEPS as i32,
},
),
edge: linear("Knurl edge", d.edge, 5., 50., " %"),
level_jitter: linear("Knurl level jitter", d.level_jitter, 0., 6., " dB"),
time_jitter: linear("Knurl time jitter", d.time_jitter, 0., 10., " %"),
grit: linear("Knurl grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(
|v| {
if v <= GRIT_OFF {
"Off".into()
} else {
format!("{v:.1}")
}
},
)),
tone: FloatParam::new(
"Knurl tone",
d.tone,
FloatRange::Skewed {
min: 4.,
max: 64.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" ×f0"),
position: linear("Knurl position", d.position, 0., 100., " %"),
length: linear("Knurl length", d.length, 2., 50., " %"),
mix: linear("Knurl mix", d.mix, 0., 100., " %"),
output: linear("Knurl output", d.output, -12., 12., " dB"),
snap: BoolParam::new("Knurl snap to semitone", d.snap),
}
}
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 25;
#[derive(Params)]
pub struct ForgeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "gate"]
pub gate: FloatParam,
#[id = "order"]
pub order: EnumParam<OrderParam>,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(id_prefix = "lathe", group = "Lathe")]
pub lathe: LatheParams,
#[nested(id_prefix = "temper", group = "Temper")]
pub temper: TemperParams,
#[nested(id_prefix = "knurl", group = "Knurl")]
pub knurl: KnurlParams,
}
impl Default for ForgeParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(forge_ui::WIDTH, forge_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
gate: linear("Gate", d.gate, -60., -20., " dB"),
order: EnumParam::new("Order", OrderParam::LatheTemperKnurl),
bypass: BoolParam::new("Bypass", false).make_bypass(),
lathe: LatheParams::default(),
temper: TemperParams::default(),
knurl: KnurlParams::default(),
}
}
}
const CURVES: [(CurveParam, Curve); 4] = [
(CurveParam::Soft, Curve::Soft),
(CurveParam::Hard, Curve::Hard),
(CurveParam::Fold, Curve::Fold),
(CurveParam::Tube, Curve::Tube),
];
const ORDER_PARAMS: [OrderParam; 6] = [
OrderParam::LatheTemperKnurl,
OrderParam::TemperLatheKnurl,
OrderParam::LatheKnurlTemper,
OrderParam::TemperKnurlLathe,
OrderParam::KnurlLatheTemper,
OrderParam::KnurlTemperLathe,
];
/// Every continuous control's value, in the order of `ForgeParams::floats`.
fn float_values(s: &Settings) -> [f32; FLOATS] {
let (l, t, k) = (&s.lathe, &s.temper, &s.knurl);
[
s.gate,
l.tilt,
l.odd_even,
l.between,
l.width,
l.smooth,
l.mix,
l.output,
t.drive,
t.bias,
t.normalize,
t.floor,
t.push_gain,
t.bright,
t.mix,
t.output,
k.edge,
k.level_jitter,
k.time_jitter,
k.grit,
k.tone,
k.position,
k.length,
k.mix,
k.output,
]
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, old: P::Plain, new: P::Plain)
where
P::Plain: PartialEq,
{
if old != new {
setter.begin_set_parameter(p);
setter.set_parameter(p, new);
setter.end_set_parameter(p);
}
}
impl ForgeParams {
/// The drawn data. Falls back to defaults while the editor holds a lock.
pub fn drawn(&self) -> Drawn {
let mut d = Drawn::default();
if let Ok(g) = self.lathe.gains.read() {
d.gains = *g;
}
if let Ok(p) = self.knurl.pattern.read() {
d.pattern = *p;
}
d
}
/// Current settings. Drawn data falls back to `last` when the editor holds a lock, so the
/// audio thread never waits.
pub fn settings(&self, last: &Drawn) -> Settings {
let gains = self
.lathe
.gains
.try_read()
.map(|g| *g)
.unwrap_or(last.gains);
let pattern = self
.knurl
.pattern
.try_read()
.map(|p| *p)
.unwrap_or(last.pattern);
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
let order = ORDER_PARAMS
.iter()
.position(|o| *o == self.order.value())
.unwrap_or(0);
let curve = self.temper.curve.value();
let mut s = Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
gate: self.gate.value(),
order: ORDERS[order],
lathe: lathe_core::Settings {
gains: gains.map(|g| {
if g.is_finite() {
g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX)
} else {
0.
}
}),
tilt: l.tilt.value(),
odd_even: l.odd_even.value(),
between: l.between.value(),
width: l.width.value(),
harmonics: l.harmonics.value() as u32,
smooth: l.smooth.value(),
snap: l.snap.value(),
mix: l.mix.value(),
output: l.output.value(),
bypass: !l.on.value(),
..lathe_core::Settings::default()
},
temper: temper_core::Settings {
drive: t.drive.value(),
curve: CURVES
.iter()
.find(|(p, _)| *p == curve)
.map_or(Curve::Soft, |(_, c)| *c),
bias: t.bias.value(),
normalize: t.normalize.value(),
floor: t.floor.value(),
push_harmonic: t.push_harmonic.value() as u32,
push_gain: t.push_gain.value(),
bright: t.bright.value(),
auto_gain: t.auto_gain.value(),
mix: t.mix.value(),
output: t.output.value(),
bypass: !t.on.value(),
..temper_core::Settings::default()
},
knurl: knurl_core::Settings {
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
} else {
0.
}
}),
grit_steps: pattern.grit,
steps: k.steps.value().clamp(1, STEPS as i32) as usize,
edge: k.edge.value(),
level_jitter: k.level_jitter.value(),
time_jitter: k.time_jitter.value(),
grit: k.grit.value(),
tone: k.tone.value(),
position: k.position.value(),
length: k.length.value(),
seed: match k.seed.value() {
SeedParam::Same => Seed::Same,
SeedParam::Free => Seed::Free,
},
snap: k.snap.value(),
mix: k.mix.value(),
output: k.output.value(),
bypass: !k.on.value(),
..knurl_core::Settings::default()
},
bypass: self.bypass.value(),
};
s.share();
s
}
fn floats(&self) -> [&FloatParam; FLOATS] {
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
[
&self.gate,
&l.tilt,
&l.odd_even,
&l.between,
&l.width,
&l.smooth,
&l.mix,
&l.output,
&t.drive,
&t.bias,
&t.normalize,
&t.floor,
&t.push_gain,
&t.bright,
&t.mix,
&t.output,
&k.edge,
&k.level_jitter,
&k.time_jitter,
&k.grit,
&k.tone,
&k.position,
&k.length,
&k.mix,
&k.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture. Drawn data is stored
/// directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.lathe.gains != old.lathe.gains {
if let Ok(mut g) = self.lathe.gains.write() {
*g = new.lathe.gains;
}
}
if new.knurl.pattern != old.knurl.pattern || new.knurl.grit_steps != old.knurl.grit_steps {
if let Ok(mut p) = self.knurl.pattern.write() {
*p = Pattern {
gains: new.knurl.pattern,
grit: new.knurl.grit_steps,
};
}
}
let (a, b) = (float_values(old), float_values(new));
for (i, p) in self.floats().into_iter().enumerate() {
if a[i] != b[i] {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b[i]);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
switch(setter, &self.bypass, old.bypass, new.bypass);
switch(setter, &l.on, !old.lathe.bypass, !new.lathe.bypass);
switch(setter, &t.on, !old.temper.bypass, !new.temper.bypass);
switch(setter, &k.on, !old.knurl.bypass, !new.knurl.bypass);
switch(setter, &l.snap, old.lathe.snap, new.lathe.snap);
switch(setter, &k.snap, old.knurl.snap, new.knurl.snap);
switch(
setter,
&t.auto_gain,
old.temper.auto_gain,
new.temper.auto_gain,
);
switch(
setter,
&l.harmonics,
old.lathe.harmonics as i32,
new.lathe.harmonics as i32,
);
switch(
setter,
&t.push_harmonic,
old.temper.push_harmonic as i32,
new.temper.push_harmonic as i32,
);
switch(
setter,
&k.steps,
old.knurl.steps as i32,
new.knurl.steps as i32,
);
let source = |s: Source| match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, source(old.source), source(new.source));
let order =
|s: &Settings| ORDER_PARAMS[ORDERS.iter().position(|o| *o == s.order).unwrap_or(0)];
switch(setter, &self.order, order(old), order(new));
let curve = |c: Curve| {
CURVES
.iter()
.find(|(_, k)| *k == c)
.map_or(CurveParam::Soft, |(p, _)| *p)
};
switch(
setter,
&t.curve,
curve(old.temper.curve),
curve(new.temper.curve),
);
let seed = |s: Seed| match s {
Seed::Same => SeedParam::Same,
Seed::Free => SeedParam::Free,
};
switch(setter, &k.seed, seed(old.knurl.seed), seed(new.knurl.seed));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 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 = &forge::clap_entry;
assert!(entry.init.unwrap()(c"forge.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"Forge");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.forge"
);
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;
envelope
.params
.insert("lathe_tilt".into(), ParamValue::F32(-3.));
envelope
.params
.insert("lathe_harmonics".into(), ParamValue::I32(16));
let mut gains = [0f32; 32];
gains[2] = -12.;
gains[4] = 6.;
envelope
.fields
.insert("lathe_gains".into(), serde_json::to_string(&gains).unwrap());
envelope
.params
.insert("lathe_snap".into(), ParamValue::Bool(false));
// Every module off: the strip passes the input, delayed.
for module in ["lathe", "temper", "knurl"] {
envelope
.params
.insert(format!("{module}_on"), ParamValue::Bool(false));
}
envelope.params.insert(
"order".into(),
ParamValue::String("knurl-lathe-temper".into()),
);
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<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()
);
let saved: [f32; 32] = serde_json::from_str(&restored.fields["lathe_gains"]).unwrap();
assert_eq!(saved, gains);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// One lookahead (about 67 ms), Temper's oversampling and Knurl's jitter headroom.
assert!((delay as f64 / rate - 0.072).abs() < 0.003, "{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);
// With every module off a 1 kHz tone passes unchanged.
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()();
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "forge-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
forge-core = { path = "../core" }
lathe-core = { path = "../../lathe/core" }
temper-core = { path = "../../temper/core" }
knurl-core = { path = "../../knurl/core" }
lathe-ui = { path = "../../lathe/ui" }
temper-ui = { path = "../../temper/ui" }
knurl-ui = { path = "../../knurl/ui" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+175
View File
@@ -0,0 +1,175 @@
//! Forge editor, shared by the CLAP wrapper and the headless renderer. The module pages are
//! the standalone editors' bodies.
use common_ui::*;
use egui::{self, RichText};
use forge_core::{AnalyzerFrame, Settings, Source, Stage, STAGES};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 730;
pub struct State {
pub lathe: lathe_ui::State,
pub temper: temper_ui::State,
pub knurl: knurl_ui::State,
/// Module whose page is shown.
pub page: Stage,
latency: u32,
sample_rate: f32,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
lathe: lathe_ui::State::default(),
temper: temper_ui::State::default(),
knurl: knurl_ui::State::default(),
page: Stage::Lathe,
latency: 0,
sample_rate: 48000.,
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str = "One tracker for every module. Pick a module to edit it; switch \
modules on and off and change their order in the chain.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
self.lathe.accept_frame(frame.lathe);
self.temper.accept_frame(frame.temper);
self.knurl.accept_frame(frame.knurl);
self.latency = frame.latency;
self.sample_rate = frame.sample_rate;
}
}
fn hint(r: &egui::Response, help: &mut String, text: &str) {
if r.hovered() || r.has_focus() {
*help = text.into();
}
}
fn subtitle(stage: Stage) -> &'static str {
match stage {
Stage::Lathe => "harmonic EQ",
Stage::Temper => "tracked distortion",
Stage::Knurl => "cycle texture",
}
}
/// The chain: modules in order, each with its switch, and the swaps between neighbours.
fn chain(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.horizontal(|ui| {
caption(ui, "CHAIN");
ui.add_space(8.);
for i in 0..STAGES {
if i > 0 {
let r = ui.button(RichText::new("<>").size(12.).color(MUTED));
if r.clicked() {
settings.order.swap(i - 1, i);
}
hint(&r, &mut state.help, "Swap the two modules next to it.");
}
let stage = settings.order[i];
let on = settings.on(stage);
let name = RichText::new(stage.name().to_uppercase()).size(14.);
let r =
ui.selectable_label(state.page == stage, if on { name } else { name.color(DIM) });
if r.clicked() {
state.page = stage;
}
hint(
&r,
&mut state.help,
&format!("Show {} ({}).", stage.name(), subtitle(stage)),
);
let r = ui.selectable_label(on, RichText::new("ON").size(11.));
if r.clicked() {
settings.set_on(stage, !on);
}
hint(
&r,
&mut state.help,
"Switch the module on or off. Off, it still delays the audio, so the latency \
stays.",
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!(
"{:.0} ms latency, one lookahead",
state.latency as f32 / state.sample_rate.max(1.) * 1000.
))
.size(12.)
.color(MUTED),
);
ui.separator();
match state.page {
Stage::Lathe => lathe_ui::toggles(ui, &mut settings.lathe),
Stage::Temper => temper_ui::toggles(ui, &mut settings.temper),
Stage::Knurl => knurl_ui::toggles(ui, &mut settings.knurl),
}
});
});
}
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());
settings.share();
ui.add_space(2.);
header(ui, "FORGE", "TRACKED CHANNEL STRIP", |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.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
chain(ui, settings, state);
});
// A module page edits the module's own settings. Its Gate knob sets the strip's gate.
let gate = match state.page {
Stage::Lathe => {
state.lathe.help.clear();
lathe_ui::body(ui, &mut settings.lathe, &mut state.lathe);
module_help(&mut state.help, &state.lathe.help);
settings.lathe.gate
}
Stage::Temper => {
state.temper.help.clear();
temper_ui::body(ui, &mut settings.temper, &mut state.temper);
module_help(&mut state.help, &state.temper.help);
settings.temper.gate
}
Stage::Knurl => {
state.knurl.help.clear();
knurl_ui::body(ui, &mut settings.knurl, &mut state.knurl);
module_help(&mut state.help, &state.knurl.help);
settings.knurl.gate
}
};
settings.gate = gate;
settings.share();
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
fn module_help(help: &mut String, module: &str) {
if !module.is_empty() {
*help = module.into();
}
}
+105 -38
View File
@@ -8,7 +8,7 @@
//! after `new`; mono-safe (both channels get the same treatment).
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
@@ -95,15 +95,15 @@ struct Pitch {
sounding: bool,
}
pub struct Engine {
/// The cycle texture without its tracker: driven by note events aligned with its input. Its
/// latency is the timing-jitter headroom and one sample for the interpolation.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
/// Tracker lookahead.
lookahead: usize,
sounding: bool,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
@@ -137,7 +137,7 @@ pub struct Engine {
meter_count: usize,
}
impl Engine {
impl Module {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
@@ -145,22 +145,19 @@ impl Engine {
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize;
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
lookahead,
sounding: false,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_line: vec![Pitch::default(); headroom + 2],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
// Reads reach `2·headroom + 1` back, plus the interpolation taps.
audio: vec![[0.; 2]; 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
@@ -189,9 +186,6 @@ impl Engine {
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
@@ -200,7 +194,6 @@ impl Engine {
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
@@ -213,7 +206,7 @@ impl Engine {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.sounding = false;
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
@@ -226,12 +219,6 @@ impl Engine {
self.scope_valid = false;
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);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32) -> [f32; 2] {
@@ -252,21 +239,15 @@ impl Engine {
}
out
}
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));
fn run(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
let (hz, jump) = self.pitch.step(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
// Note events are aligned with the input; the audio leaves `headroom + 1` later.
let len = self.pitch_line.len();
self.pitch_line[self.pitch_at] = Pitch {
hz,
start: jump,
sounding: self.tracker.sounding(),
sounding,
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
@@ -274,7 +255,7 @@ impl Engine {
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let centre = (self.lookahead + self.headroom) as f32;
let centre = (self.headroom + 1) as f32;
let dry = self.read(centre);
// Cycle clock.
@@ -380,7 +361,8 @@ impl Engine {
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
/// Editor data; levels are averaged since the previous call. `latency` is left at 0 for
/// the owner to fill in.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.meter_count.max(1) as f64;
let db = |p: f64| (10. * (p / n).max(1e-12).log10()).max(-120.) as f32;
@@ -392,10 +374,10 @@ impl Engine {
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
latency: 0,
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
if self.sounding && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
@@ -404,3 +386,88 @@ impl Engine {
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
self.headroom + 1
}
fn event(&mut self, e: NoteEvent) {
// Every note restarts the clock, legato or not.
let e = match e {
NoteEvent::Start { pitch, .. } => NoteEvent::Start {
pitch,
glide: false,
},
e => e,
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] {
self.run(x, sounding)
}
}
/// Standalone Knurl: its own tracker in front of the [`Module`].
pub struct Engine {
channels: usize,
lookahead: Lookahead,
module: Module,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
let module = Module::new(rate, channels)?;
let mut e = Self {
channels,
lookahead: Lookahead::new(rate, TrackerConfig::BASS),
module,
};
// The module already holds the defaults; only the tracker still needs them.
e.set_tracker(&Settings::default());
Ok(e)
}
/// Lookahead, jitter headroom and one interpolation sample.
pub fn latency_samples(&self) -> u32 {
(self.lookahead.latency() + self.module.latency()) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.module.settings()
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
self.module.set_settings(s)?;
self.set_tracker(&s);
Ok(())
}
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, false);
}
pub fn reset(&mut self) {
self.lookahead.reset();
self.module.reset();
}
pub fn note_on(&mut self, note: u8) {
self.lookahead.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.lookahead.tracker.note_off(note);
}
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]
};
self.lookahead.process(x, mono, &mut self.module)
}
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
AnalyzerFrame {
latency: self.latency_samples(),
..self.module.take_analyzer()
}
}
}
+10 -2
View File
@@ -86,6 +86,13 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
caption(ui, "PITCH FROM");
});
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Everything under the header: the standalone editor and Forge both draw it.
pub fn body(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(6.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
@@ -109,8 +116,6 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
@@ -499,3 +504,6 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
);
});
}
/// Header switches besides Bypass and the pitch source: none here.
pub fn toggles(_: &mut egui::Ui, _: &mut Settings) {}
+119 -62
View File
@@ -6,7 +6,7 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
@@ -43,14 +43,12 @@ impl Default for AnalyzerFrame {
}
}
pub struct Engine {
/// The harmonic EQ without its tracker: tuned by note events aligned with its input. No
/// latency of its own.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
@@ -66,29 +64,29 @@ pub struct Engine {
bypass: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
sounding: bool,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
impl Engine {
fn check(rate: f32, channels: usize) -> Result<(), &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
Ok(())
}
impl Module {
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();
check(rate, channels)?;
let settings = Settings::default();
let mut e = Self {
let mut m = Self {
rate,
channels,
settings,
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
tracker,
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
@@ -104,25 +102,19 @@ impl Engine {
bypass: 0.,
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
sounding: false,
fresh: true,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
m.set_settings(settings)?;
m.fresh = true;
Ok(m)
}
pub fn settings(&self) -> Settings {
self.settings
}
/// `source` and `gate` belong to the tracker and are ignored here.
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.);
self.settings = s;
if std::mem::take(&mut self.fresh) {
@@ -134,8 +126,9 @@ impl Engine {
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.pitch = PitchFollower::default();
self.hz = 0.;
self.sounding = false;
for ch in &mut self.state {
for f in ch.iter_mut() {
f.reset();
@@ -146,12 +139,6 @@ impl Engine {
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);
}
/// Gain targets in dB of every band for the current settings.
fn targets(&self) -> [f32; BANDS] {
let s = &self.settings;
@@ -193,19 +180,39 @@ impl Engine {
}
}
}
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]
/// Editor data. `latency` is left at 0 for the owner to fill in.
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
..AnalyzerFrame::default()
};
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];
if self.sounding && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
0
}
fn event(&mut self, e: NoteEvent) {
// Legato notes slide only when Smooth is on.
let e = match e {
NoteEvent::Start { pitch, glide } => NoteEvent::Start {
pitch,
glide: glide && self.settings.smooth > 0.,
},
e => e,
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
let first = self.hz == 0.;
let (hz, jump) = self.pitch.step(self.hz_coef);
let first = first && jump;
@@ -231,7 +238,7 @@ impl Engine {
let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output;
out[c] = wet * (1. - self.bypass) + y[c] * self.bypass;
}
let hz = if self.tracker.sounding() { self.hz } else { 0. };
let hz = if sounding { self.hz } else { 0. };
let (dry, wet) = if self.channels == 2 {
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
} else {
@@ -241,18 +248,68 @@ impl Engine {
self.meter_out.push(wet, hz);
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
latency: self.latency as u32,
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
..AnalyzerFrame::default()
}
/// Standalone Lathe: its own tracker in front of the [`Module`].
pub struct Engine {
channels: usize,
lookahead: Lookahead,
module: Module,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
let module = Module::new(rate, channels)?;
let mut e = Self {
channels,
lookahead: Lookahead::new(rate, TrackerConfig::BASS),
module,
};
if self.tracker.sounding() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
// The module already holds the defaults; only the tracker still needs them.
e.set_tracker(&Settings::default());
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.lookahead.latency() as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.module.settings()
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
self.module.set_settings(s)?;
self.set_tracker(&s);
Ok(())
}
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
}
pub fn reset(&mut self) {
self.lookahead.reset();
self.module.reset();
}
pub fn note_on(&mut self, note: u8) {
self.lookahead.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.lookahead.tracker.note_off(note);
}
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]
};
self.lookahead.process(x, mono, &mut self.module)
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
AnalyzerFrame {
latency: self.latency_samples(),
..self.module.take_analyzer()
}
f
}
}
+10 -2
View File
@@ -285,6 +285,13 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
caption(ui, "PITCH FROM");
});
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Everything under the header: the standalone editor and Forge both draw it.
pub fn body(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -304,8 +311,6 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 330.;
@@ -429,3 +434,6 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Output trim.");
});
}
/// Header switches besides Bypass and the pitch source: none here.
pub fn toggles(_: &mut egui::Ui, _: &mut Settings) {}
+7 -8
View File
@@ -3,14 +3,10 @@ mod pitch;
mod settings;
use pitch::PitchProbe;
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
pub use settings::{focus_offset, knee, Focus, Settings};
pub use settings::{focus_offset, knee, Focus, Knee, Settings, RATIO_INFINITE};
use std::{f32::consts::PI, sync::Arc};
pub const GRID: usize = 512;
/// dB of cut per dB of prominence above the threshold.
const SLOPE: f32 = 1.5;
/// Fixed soft-knee width, in dB of prominence.
const KNEE: f32 = 3.;
/// Neighboring peaks closer than this (Hz, or one octave) are joined by a
/// straight line in dB, so harmonic gaps do not count as expected level.
const JOIN_HZ: f32 = 800.;
@@ -463,7 +459,9 @@ impl Engine {
let fall = 1. - (-frame_time / 0.04).exp();
let awake = self.sample_power / self.hop as f32 > 1e-9;
self.sample_power = 0.;
let threshold = self.settings.threshold();
let threshold = self.settings.threshold;
let slope = self.settings.slope();
let knee_width = self.settings.knee_width();
self.target.fill(0.);
self.frame.input.fill(-200.);
self.frame.envelope.fill(-200.);
@@ -588,7 +586,8 @@ impl Engine {
// material is never cut.
let live = awake && content[i] && level > -130.;
let target = if live {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
(knee(excess, knee_width) * slope).min(self.settings.depth)
* (1. - self.tone[i])
} else {
0.
};
@@ -609,7 +608,7 @@ impl Engine {
}
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();
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack_time() * 0.001)).exp();
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
for i in 0..GRID {
let target = self.target[i];
+114 -16
View File
@@ -19,15 +19,34 @@ impl Default for Focus {
}
}
}
/// Shape of each band's gain curve around the threshold.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Knee {
/// Wide knee: cuts fade in gradually around the threshold.
#[default]
Soft,
/// Narrow knee and a faster attack: cuts start right at the threshold.
Hard,
}
/// Ratio at or above this value is treated as ∞:1.
pub const RATIO_INFINITE: f32 = 20.;
/// dB of cut per dB of detected prominence at ∞:1. The detector smooths a bump across
/// frequency and reads it lower than it is; this restores the true height.
const CALIBRATION: f32 = 1.5;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[serde(from = "Stored")]
pub struct Settings {
/// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper.
/// Largest cut in dB per band.
pub depth: f32,
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
pub sharpness: f32,
/// 0 % treats any bump; 100 % treats only the most prominent bumps.
pub selectivity: f32,
/// How far (dB) a band must rise above its local floor before it is cut.
pub threshold: f32,
/// Cut per dB above the threshold: at n:1 a bump ends 1/n as far above the threshold.
/// `RATIO_INFINITE` and above flatten bumps to the threshold.
pub ratio: f32,
pub knee: Knee,
pub attack: f32,
pub release: f32,
pub transient_guard: f32,
@@ -39,15 +58,68 @@ pub struct Settings {
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,
}
/// Stored form. Settings saved before 0.5 have Selectivity (%) instead of Threshold, and no
/// Ratio, Knee or Tone guard.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Stored {
depth: f32,
sharpness: f32,
#[serde(default)]
threshold: Option<f32>,
#[serde(default)]
selectivity: Option<f32>,
#[serde(default)]
ratio: Option<f32>,
#[serde(default)]
knee: Knee,
attack: f32,
release: f32,
transient_guard: f32,
low: f32,
high: f32,
mix: f32,
output: f32,
bypass: bool,
focus: [Focus; 4],
#[serde(default)]
tone_guard: f32,
}
impl From<Stored> for Settings {
fn from(s: Stored) -> Self {
let d = Settings::default();
Self {
depth: s.depth,
sharpness: s.sharpness,
threshold: s
.threshold
.or(s.selectivity.map(|v| 0.5 + v * 0.05))
.unwrap_or(d.threshold),
ratio: s.ratio.unwrap_or(d.ratio),
knee: s.knee,
attack: s.attack,
release: s.release,
transient_guard: s.transient_guard,
low: s.low,
high: s.high,
mix: s.mix,
output: s.output,
bypass: s.bypass,
focus: s.focus,
tone_guard: s.tone_guard,
}
}
}
impl Default for Settings {
fn default() -> Self {
Self {
depth: 6.,
sharpness: 40.,
selectivity: 50.,
threshold: 3.,
ratio: RATIO_INFINITE,
knee: Knee::Soft,
attack: 5.,
release: 80.,
transient_guard: 30.,
@@ -66,7 +138,8 @@ impl Settings {
for (v, low, high) in [
(self.depth, 0., 20.),
(self.sharpness, 0., 100.),
(self.selectivity, 0., 100.),
(self.threshold, 0., 18.),
(self.ratio, 1., RATIO_INFINITE),
(self.attack, 1., 200.),
(self.release, 10., 1000.),
(self.transient_guard, 0., 100.),
@@ -104,9 +177,27 @@ impl Settings {
pub fn reference_width(&self) -> f32 {
(self.envelope_width() * 16.).clamp(2., 3.)
}
/// Prominence threshold in dB above the local reference, from Selectivity.
pub fn threshold(&self) -> f32 {
0.5 + self.selectivity * 0.05
/// dB of cut per dB of detected prominence above the threshold.
pub fn slope(&self) -> f32 {
if self.ratio >= RATIO_INFINITE {
CALIBRATION
} else {
CALIBRATION * (1. - 1. / self.ratio)
}
}
/// Soft-knee width in dB of prominence.
pub fn knee_width(&self) -> f32 {
match self.knee {
Knee::Soft => 6.,
Knee::Hard => 1.,
}
}
/// Attack time actually used: the hard knee grips twice as fast.
pub fn attack_time(&self) -> f32 {
match self.knee {
Knee::Soft => self.attack,
Knee::Hard => (self.attack * 0.5).max(0.5),
}
}
pub const PRESETS: [&'static str; 5] = [
"Gentle cleanup",
@@ -121,18 +212,21 @@ impl Settings {
1 => {
s.low = 1500.;
s.high = 12000.;
s.depth = 8.;
s.depth = 10.;
s.sharpness = 55.;
s.selectivity = 40.;
s.threshold = 2.5;
s.ratio = 3.;
s.attack = 3.;
s.release = 60.;
}
2 => {
s.low = 250.;
s.high = 6000.;
s.depth = 8.;
s.depth = 12.;
s.sharpness = 75.;
s.selectivity = 35.;
s.threshold = 2.5;
s.ratio = 6.;
s.knee = Knee::Hard;
s.attack = 10.;
s.release = 150.;
s.transient_guard = 80.;
@@ -140,14 +234,18 @@ impl Settings {
3 => {
s.low = 2000.;
s.high = 16000.;
s.depth = 9.;
s.sharpness = 25.;
s.selectivity = 45.;
s.threshold = 2.5;
s.ratio = 2.5;
}
4 => {
s.low = 150.;
s.high = 12000.;
s.depth = 8.;
s.sharpness = 50.;
s.selectivity = 45.;
s.threshold = 2.5;
s.ratio = 3.;
s.tone_guard = 80.;
}
_ => {}
+103 -3
View File
@@ -148,7 +148,7 @@ fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
}
}
#[test]
fn depth_selectivity_and_focus_each_do_one_thing() {
fn depth_threshold_and_focus_each_do_one_thing() {
let run = |s: Settings| {
let mut bump = Peak::new(3000., 4., 12., 48000.);
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
@@ -160,7 +160,7 @@ fn depth_selectivity_and_focus_each_do_one_thing() {
..Default::default()
});
let picky = run(Settings {
selectivity: 100.,
threshold: 5.5,
..Default::default()
});
let mut protect = Settings::default();
@@ -279,7 +279,7 @@ fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
}
#[test]
fn soft_knee_and_presets_are_valid() {
for i in 0..4 {
for i in 0..Settings::PRESETS.len() {
Settings::preset(i).validate().unwrap();
}
assert_eq!(knee(-2., 3.), 0.);
@@ -469,3 +469,103 @@ fn old_settings_without_tone_guard_still_load() {
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.tone_guard, 0.);
}
/// Cut at the bump for a resonance of `gain` dB (Q 4 at 3 kHz) on a 220 Hz saw.
fn bump_cut(s: Settings, gain: f32) -> f32 {
let mut bump = Peak::new(3000., 4., gain, 48000.);
let frame = settle(
Settings {
transient_guard: 0.,
..s
},
|i| bump.tick(saw(220., i, 48000.)),
);
peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26)
}
#[test]
fn ratio_spares_small_peaks_and_still_cuts_big_ones() {
let at = |ratio: f32, gain: f32| {
bump_cut(
Settings {
depth: 20.,
ratio,
..Default::default()
},
gain,
)
};
let (small_hard, big_hard) = (at(RATIO_INFINITE, 6.), at(RATIO_INFINITE, 18.));
let (small, big) = (at(2., 6.), at(2., 18.));
// At 2:1 each cut is about half the ∞:1 cut.
for (half, full) in [(small, small_hard), (big, big_hard)] {
assert!((half / full - 0.5).abs() < 0.15, "{half} vs {full}");
}
assert!(small < 1., "small bump cut by {small} dB at 2:1");
assert!(big > 4., "big bump cut only {big} dB at 2:1");
// Ratio between the two ends gives a cut between them.
let mid = at(4., 18.);
assert!(big < mid && mid < big_hard, "{big} < {mid} < {big_hard}");
}
#[test]
fn cut_grows_with_the_bump_until_depth() {
let s = Settings {
depth: 20.,
ratio: 3.,
..Default::default()
};
let cuts: Vec<f32> = [4., 8., 12., 18., 24.]
.iter()
.map(|g| bump_cut(s, *g))
.collect();
for w in cuts.windows(2) {
assert!(w[1] > w[0] + 0.5, "{cuts:?}");
}
let capped = bump_cut(Settings { depth: 4., ..s }, 24.);
assert!(capped <= 4.001 && capped > 3., "{capped}");
}
#[test]
fn threshold_moves_where_cuts_start() {
let low = bump_cut(Settings::default(), 9.);
let high = bump_cut(
Settings {
threshold: 9.,
..Default::default()
},
9.,
);
assert!(low > 2. && high < 0.5, "{low} / {high}");
}
#[test]
fn hard_knee_starts_cutting_closer_to_the_threshold() {
// A bump just under the threshold: the soft knee already cuts a little, the hard knee not.
let s = Settings {
threshold: 4.,
..Default::default()
};
let soft = bump_cut(s, 6.);
let hard = bump_cut(
Settings {
knee: Knee::Hard,
..s
},
6.,
);
assert!(soft > hard + 0.2, "soft {soft} vs hard {hard}");
assert_eq!(knee(0.25, 1.), 0.28125);
}
#[test]
fn settings_from_before_threshold_still_load() {
let mut json = serde_json::to_value(Settings::default()).unwrap();
let map = json.as_object_mut().unwrap();
for key in ["threshold", "ratio", "knee", "tone_guard"] {
map.remove(key);
}
map.insert("selectivity".into(), 100.into());
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.threshold, 5.5);
assert_eq!(s.ratio, RATIO_INFINITE);
assert_eq!(s.knee, Knee::Soft);
assert!(serde_json::from_str::<Settings>(r#"{"oops":1}"#).is_err());
let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
+1 -1
View File
@@ -1423,7 +1423,7 @@ dependencies = [
[[package]]
name = "patina"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
license = "ISC"
+9
View File
@@ -105,6 +105,15 @@ impl Plugin for Patina {
state.gestures[i] = false;
}
}
if edited.knee != before.knee {
let knee = match edited.knee {
patina_core::Knee::Soft => params::KneeParam::Soft,
patina_core::Knee::Hard => params::KneeParam::Hard,
};
setter.begin_set_parameter(&params.knee);
setter.set_parameter(&params.knee, knee);
setter.end_set_parameter(&params.knee);
}
if edited.bypass != before.bypass {
setter.begin_set_parameter(&params.bypass);
setter.set_parameter(&params.bypass, edited.bypass);
+64 -9
View File
@@ -1,6 +1,6 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use patina_core::{Focus, Settings};
use patina_core::{Focus, Knee, Settings, RATIO_INFINITE};
use std::sync::Arc;
#[derive(Params)]
pub struct FocusParams {
@@ -38,6 +38,13 @@ impl Default for FocusParams {
}
}
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum KneeParam {
#[id = "soft"]
Soft,
#[id = "hard"]
Hard,
}
#[derive(Params)]
pub struct PatinaParams {
#[persist = "editor"]
@@ -46,8 +53,12 @@ pub struct PatinaParams {
pub depth: FloatParam,
#[id = "sharpness"]
pub sharpness: FloatParam,
#[id = "selectivity"]
pub selectivity: FloatParam,
#[id = "threshold"]
pub threshold: FloatParam,
#[id = "ratio"]
pub ratio: FloatParam,
#[id = "knee"]
pub knee: EnumParam<KneeParam>,
#[id = "attack"]
pub attack: FloatParam,
#[id = "release"]
@@ -76,7 +87,36 @@ impl Default for PatinaParams {
editor: EguiState::from_size(1180, 760),
depth: linear("Depth", d.depth, 0., 20., " dB"),
sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"),
selectivity: linear("Selectivity", d.selectivity, 0., 100., " %"),
threshold: linear("Threshold", d.threshold, 0., 18., " dB"),
ratio: FloatParam::new(
"Ratio",
d.ratio,
FloatRange::Skewed {
min: 1.,
max: RATIO_INFINITE,
factor: FloatRange::skew_factor(-1.5),
},
)
.with_value_to_string(Arc::new(ratio_text))
.with_string_to_value(Arc::new(|s| {
let s = s
.trim()
.trim_end_matches(":1")
.trim_end_matches(": 1")
.trim();
if s == "∞" || s.eq_ignore_ascii_case("inf") {
Some(RATIO_INFINITE)
} else {
s.parse().ok()
}
})),
knee: EnumParam::new(
"Knee",
match d.knee {
Knee::Soft => KneeParam::Soft,
Knee::Hard => KneeParam::Hard,
},
),
attack: log("Attack", d.attack, 1., 200., " ms"),
release: log("Release", d.release, 10., 1000., " ms"),
transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"),
@@ -95,7 +135,7 @@ impl PatinaParams {
[
&self.depth,
&self.sharpness,
&self.selectivity,
&self.threshold,
&self.attack,
&self.release,
&self.transient_guard,
@@ -120,13 +160,19 @@ impl PatinaParams {
&self.focus[3].width,
&self.focus[3].sensitivity,
&self.tone_guard,
&self.ratio,
]
}
pub fn settings(&self) -> Settings {
let mut s = Settings {
depth: self.depth.value(),
sharpness: self.sharpness.value(),
selectivity: self.selectivity.value(),
threshold: self.threshold.value(),
ratio: self.ratio.value(),
knee: match self.knee.value() {
KneeParam::Soft => Knee::Soft,
KneeParam::Hard => Knee::Hard,
},
attack: self.attack.value(),
release: self.release.value(),
transient_guard: self.transient_guard.value(),
@@ -150,14 +196,22 @@ impl PatinaParams {
s
}
}
/// Host float parameters: ten main controls, four per focus point, then Tone guard.
pub const COUNT: usize = 27;
/// Ratio as the host shows it: "4.0:1", and "∞:1" at the top of the range.
fn ratio_text(v: f32) -> String {
if v >= RATIO_INFINITE {
"∞:1".into()
} else {
format!("{v:.1}:1")
}
}
/// Host float parameters: ten main controls, four per focus point, Tone guard, then Ratio.
pub const COUNT: usize = 28;
pub fn values(s: &Settings) -> [f32; COUNT] {
let mut v = [0.; COUNT];
v[..10].copy_from_slice(&[
s.depth,
s.sharpness,
s.selectivity,
s.threshold,
s.attack,
s.release,
s.transient_guard,
@@ -175,5 +229,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] {
]);
}
v[26] = s.tone_guard;
v[27] = s.ratio;
v
}
+7
View File
@@ -114,6 +114,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
envelope
.params
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
envelope
.params
.insert("threshold".into(), ParamValue::F32(6.));
envelope.params.insert("ratio".into(), ParamValue::F32(3.));
envelope
.params
.insert("knee".into(), ParamValue::String("hard".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);
+153 -40
View File
@@ -2,7 +2,9 @@
pub use common_ui::style;
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
use patina_core::{
focus_offset, frequency, knee, AnalyzerFrame, Focus, Knee, Settings, GRID, RATIO_INFINITE,
};
use std::time::{Duration, Instant};
pub struct State {
@@ -30,7 +32,7 @@ impl Default for State {
gesture_active: false,
gestures: [false; 30],
last_audio: Instant::now(),
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
help: "Set Depth, then raise Threshold until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
drag: None,
}
}
@@ -85,6 +87,66 @@ fn hint(response: egui::Response, state: &mut State, text: &str) {
state.help = text.into();
}
}
/// The Ratio knob is square-law, so 1:1 to 6:1 gets half its travel.
fn ratio_position(ratio: f32) -> f32 {
((ratio - 1.) / (RATIO_INFINITE - 1.)).clamp(0., 1.).sqrt() * 100.
}
fn ratio_from_position(position: f32) -> f32 {
let p = (position / 100.).clamp(0., 1.);
1. + (RATIO_INFINITE - 1.) * p * p
}
fn ratio_text(ratio: f32) -> String {
if ratio >= RATIO_INFINITE {
"∞:1".into()
} else {
format!("{ratio:.1}:1")
}
}
fn parse_ratio(text: &str) -> Option<f32> {
let t = text.trim().trim_end_matches(":1").trim();
if t == "∞" || t.eq_ignore_ascii_case("inf") {
Some(RATIO_INFINITE)
} else {
t.parse::<f32>().ok().map(|r| r.clamp(1., RATIO_INFINITE))
}
}
/// Gain curve of one band: prominence above the floor in, cut out. Shows Threshold, Knee,
/// Ratio and the Depth limit together.
fn transfer(ui: &mut egui::Ui, settings: &Settings) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 62.), Sense::hover());
let p = ui.painter();
p.rect_filled(rect, 3., SCREEN);
let r = rect.shrink2(Vec2::new(6., 6.));
const IN: f32 = 18.;
const OUT: f32 = 20.;
let x = |e: f32| r.left() + e / IN * r.width();
let y = |c: f32| r.top() + c.min(OUT) / OUT * r.height();
let t = settings.threshold;
p.line_segment(
[Pos2::new(x(t), r.top()), Pos2::new(x(t), r.bottom())],
Stroke::new(1.0_f32, DIM),
);
let d = settings.depth;
p.line_segment(
[Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))],
Stroke::new(1.0_f32, Color32::from_rgb(48, 50, 56)),
);
let points: Vec<Pos2> = (0..=90)
.map(|i| {
let e = IN * i as f32 / 90.;
let cut = (knee(e - t, settings.knee_width()) * settings.slope()).min(d);
Pos2::new(x(e), y(cut))
})
.collect();
p.add(egui::Shape::line(points, Stroke::new(1.5_f32, ACCENT)));
p.text(
r.right_top(),
egui::Align2::RIGHT_TOP,
"CUT PER BUMP HEIGHT",
FontId::proportional(10.),
MUTED,
);
}
fn note(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).color(MUTED));
}
@@ -122,6 +184,7 @@ fn control(
"Low frequency" => defaults.low,
"High frequency" => defaults.high,
"Mix" => defaults.mix,
"Output" => defaults.output,
_ => *value,
};
}
@@ -249,7 +312,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let body_height = (ui.available_height() - 40.).max(480.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(176.);
ui.set_width(236.);
ui.set_min_height(body_height - 28.);
ui.vertical_centered(|ui| {
caption(ui, "DETECTION");
@@ -262,12 +325,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
0. ..=20.,
defaults.depth,
" dB",
108.,
88.,
ACCENT,
);
hint(knob, state, "Largest cut. 0 dB does nothing.");
ui.add_space(8.);
ui.columns(2, |columns| {
ui.columns(3, |columns| {
columns[0].vertical_centered(|ui| {
let knob = rotary(
ui,
@@ -284,47 +347,76 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
columns[1].vertical_centered(|ui| {
let knob = rotary(
ui,
"Selectivity",
&mut settings.selectivity,
0. ..=100.,
defaults.selectivity,
" %",
"Threshold",
&mut settings.threshold,
0. ..=18.,
defaults.threshold,
" dB",
58.,
ACCENT,
);
hint(knob, state, "Low: treat any bump. High: only the worst.");
hint(
knob,
state,
"How far a band must rise above its neighbours before it is cut. \
Raise it until only the harsh parts are cut.",
);
});
columns[2].vertical_centered(|ui| {
let mut position = ratio_position(settings.ratio);
let knob = rotary_text(
ui,
"Ratio",
&mut position,
0. ..=100.,
ratio_position(defaults.ratio),
58.,
ACCENT,
|p| ratio_text(ratio_from_position(p)),
|s| parse_ratio(s).map(ratio_position),
);
if position != ratio_position(settings.ratio) {
settings.ratio = ratio_from_position(position);
}
hint(
knob,
state,
"Cut per dB above the threshold. Low ratios leave small bumps \
nearly alone and still cut big ones; ∞:1 flattens every bump to \
the threshold.",
);
});
});
ui.add_space(10.);
ui.separator();
ui.add_space(4.);
caption(ui, "OUTPUT");
ui.add_space(4.);
ui.columns(2, |columns| {
columns[0].vertical_centered(|ui| {
rotary(
ui,
"Mix",
&mut settings.mix,
0. ..=100.,
100.,
" %",
58.,
STEEL,
);
});
columns[1].vertical_centered(|ui| {
rotary(
ui,
"Output",
&mut settings.output,
-12. ..=12.,
0.,
" dB",
58.,
STEEL,
ui.horizontal(|ui| {
ui.vertical(|ui| {
caption(ui, "KNEE");
for (knee, label, help) in [
(
Knee::Soft,
"SOFT",
"Soft knee: cuts fade in gradually around the threshold.",
),
(
Knee::Hard,
"HARD",
"Hard knee: cuts start right at the threshold and grip twice \
as fast.",
),
] {
let r = ui.selectable_label(
settings.knee == knee,
RichText::new(label).size(13.),
);
if r.clicked() {
settings.knee = knee;
}
if r.hovered() {
state.help = help.into();
}
}
});
transfer(ui, settings);
});
});
});
@@ -437,8 +529,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
});
ui.add_space(6.);
egui::Grid::new("range-controls")
.num_columns(2)
.spacing([22., 8.])
.num_columns(4)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 48.) / 4.)
.show(ui, |ui| {
control(
ui,
@@ -460,6 +553,26 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Upper processing boundary, also limited by Nyquist.",
state,
);
control(
ui,
"Mix",
&mut settings.mix,
0. ..=100.,
" %",
false,
"Blend of processed and delayed dry signal.",
state,
);
control(
ui,
"Output",
&mut settings.output,
-12. ..=12.,
" dB",
false,
"Output trim, for level-matched comparison.",
state,
);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "quench-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 }
+422
View File
@@ -0,0 +1,422 @@
//! A clipper that leaves the sub alone.
//!
//! A plain clipper clips the sum of sub and top. Near the sub's peaks only one side of the top
//! gets clipped, so the clipping takes energy out at the sub's rate: the sub loses level and
//! the low band fills with products that were not in the mix.
//!
//! Quench splits the signal with a Linkwitz–Riley crossover. The sub passes untouched. The
//! top is clipped symmetrically at the room the sub leaves under the ceiling: the ceiling
//! minus the sub's held peak. Holding the peak through the sub's cycle keeps the top's clip
//! level steady, so the top is not pumped at the sub's rate. What the clip removes is then
//! high-passed at the split, so its products below the split do not reach the low band.
//! High-passing lets a few peaks overshoot; two correction passes clip those and high-pass the
//! correction again. A hard clip at the ceiling, still oversampled, takes the small rest, and a
//! safety clip after the downsampler catches its ringing.
//!
//! Everything runs at 4× oversampling. Plain clipping of the same signal runs alongside, for
//! the Plain monitor and for the meter that compares how much each changes the low band.
mod settings;
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::{coefficient, db_to_gain, gain_to_db};
pub use settings::{Settings, CEILING_MIN, DRIVE_MAX, HOLD_MAX, HOLD_MIN, SPLIT_MAX, SPLIT_MIN};
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const FACTOR: usize = Oversampler::FACTOR;
/// Correction passes after the top's clip.
pub const PASSES: usize = 2;
/// Points in the scope, each the extremes of `SCOPE_STEP` samples.
pub const SCOPE: usize = 256;
const SCOPE_STEP: usize = 8;
const SCOPE_LEN: usize = SCOPE * SCOPE_STEP;
/// Averaging time of the low-band meter.
const METER_MS: f32 = 300.;
const SMOOTH_MS: f32 = 20.;
const BYPASS_MS: f32 = 10.;
pub const SILENT: f32 = -120.;
/// Below this sub energy (−50 dB RMS) the low-band meter has nothing to compare against.
const SUB_FLOOR: f32 = 1e-5;
/// What the output plays. Monitors are not saved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Monitor {
#[default]
Quench,
/// A plain clipper at the same Drive, Ceiling and Soft, for comparison.
Plain,
/// What Quench takes away: its output minus the unclipped input.
Delta,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Peak after Drive, in dBFS, since the last frame.
pub input_peak: f32,
pub output_peak: f32,
/// Peak of the sub band after Drive.
pub sub_peak: f32,
/// How far the top's loudest peak went past its clip level, in dB. 0 when nothing clipped.
pub top_clip: f32,
/// How far the final safety clip reached, in dB. 0 when it did nothing.
pub safety: f32,
/// Change of the low band below the split, against the sub, in dB: Quench and a plain clip
/// of the same signal. NaN without a sub.
pub low_quench: f32,
pub low_plain: f32,
pub ceiling: f32,
pub latency: u32,
pub sample_rate: f32,
/// Latest output of the selected monitor (oldest first), as extremes per point.
pub scope_min: [f32; SCOPE],
pub scope_max: [f32; SCOPE],
/// The sub band and the room it leaves the top, at the same points.
pub scope_sub: [f32; SCOPE],
pub scope_room: [f32; SCOPE],
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
input_peak: SILENT,
output_peak: SILENT,
sub_peak: SILENT,
top_clip: 0.,
safety: 0.,
low_quench: f32::NAN,
low_plain: f32::NAN,
ceiling: Settings::default().ceiling,
latency: Oversampler::latency() as u32,
sample_rate: 48000.,
scope_min: [0.; SCOPE],
scope_max: [0.; SCOPE],
scope_sub: [0.; SCOPE],
scope_room: [0.; SCOPE],
}
}
}
/// Clips `h` to ±`limit`. `knee` (0–1) is the share of the range below `limit` that bends:
/// 0 is a hard clip. Never exceeds `limit`.
#[inline]
pub fn shape(h: f32, limit: f32, knee: f32) -> f32 {
let a = limit * (1. - knee);
let m = h.abs();
if m <= a {
return h;
}
let w = limit - a;
let y = if w <= 1e-9 {
limit
} else {
a + w * ((m - a) / w).tanh()
};
y.min(limit).copysign(h)
}
/// One band of a Linkwitz–Riley crossover: two Butterworth stages.
#[inline]
fn lr4(f: &mut [Svf; 2], c: &SvfCoef, v: f32) -> f32 {
let v = f[0].process(c, v);
f[1].process(c, v)
}
#[derive(Clone, Copy)]
struct Coefs {
low: SvfCoef,
high: SvfCoef,
/// Held-peak length in oversampled samples, and its release per sample after that.
hold: usize,
release: f32,
knee: f32,
}
impl Coefs {
fn new(rate: f32, s: &Settings) -> Self {
let fast = rate * FACTOR as f32;
Self {
low: SvfCoef::lowpass(fast, s.split, Q),
high: SvfCoef::highpass(fast, s.split, Q),
hold: (s.hold * 0.001 * fast) as usize,
release: 1. - coefficient(fast, s.hold),
knee: s.soft * 0.01,
}
}
}
struct Channel {
os: Oversampler,
low: [Svf; 2],
high: [Svf; 2],
/// High-passes what the top's clip removed.
error: Svf,
passes: [Svf; PASSES],
env: f32,
hold_left: usize,
meter_quench: [Svf; 2],
meter_plain: [Svf; 2],
/// The input delayed by the latency, for bypass.
dry: Vec<f32>,
}
impl Channel {
fn new(latency: usize) -> Self {
Self {
os: Oversampler::new(),
low: [Svf::default(); 2],
high: [Svf::default(); 2],
error: Svf::default(),
passes: [Svf::default(); PASSES],
env: 0.,
hold_left: 0,
meter_quench: [Svf::default(); 2],
meter_plain: [Svf::default(); 2],
dry: vec![0.; latency + 1],
}
}
fn reset(&mut self) {
self.os.reset();
for f in self
.low
.iter_mut()
.chain(self.high.iter_mut())
.chain(self.passes.iter_mut())
.chain(self.meter_quench.iter_mut())
.chain(self.meter_plain.iter_mut())
{
f.reset();
}
self.error.reset();
self.env = 0.;
self.hold_left = 0;
self.dry.fill(0.);
}
}
/// Peaks and energies gathered between two analyzer frames.
#[derive(Clone, Copy, Default)]
struct Gather {
input: f32,
output: f32,
sub: f32,
/// Largest ratio of the top to its room.
top: f32,
safety: f32,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
coefs: Coefs,
channel: [Channel; 2],
drive: f32,
ceiling: f32,
bypass: f32,
primed: bool,
smooth: f32,
fade: f32,
meter: f32,
dry_at: usize,
energy_quench: f32,
energy_plain: f32,
energy_sub: f32,
gather: Gather,
scope: [[f32; 4]; SCOPE_LEN],
scope_at: usize,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=384000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let settings = Settings::default();
let latency = Oversampler::latency();
Ok(Self {
rate,
channels,
settings,
coefs: Coefs::new(rate, &settings),
channel: [Channel::new(latency), Channel::new(latency)],
drive: 1.,
ceiling: db_to_gain(settings.ceiling),
bypass: 0.,
primed: false,
smooth: coefficient(rate, SMOOTH_MS),
fade: coefficient(rate, BYPASS_MS),
meter: coefficient(rate * FACTOR as f32, METER_MS),
dry_at: 0,
energy_quench: 0.,
energy_plain: 0.,
energy_sub: 0.,
gather: Gather::default(),
scope: [[0.; 4]; SCOPE_LEN],
scope_at: 0,
})
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn latency_samples(&self) -> u32 {
Oversampler::latency() as u32
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
if s != self.settings || !self.primed {
self.coefs = Coefs::new(self.rate, &s);
}
self.settings = s;
if !self.primed {
// The first settings after a start take effect at once instead of ramping.
self.drive = db_to_gain(s.drive);
self.ceiling = db_to_gain(s.ceiling);
self.bypass = if s.bypass { 1. } else { 0. };
self.primed = true;
}
Ok(())
}
pub fn reset(&mut self) {
for c in &mut self.channel {
c.reset();
}
self.dry_at = 0;
self.energy_quench = 0.;
self.energy_plain = 0.;
self.energy_sub = 0.;
self.gather = Gather::default();
self.scope = [[0.; 4]; SCOPE_LEN];
self.scope_at = 0;
self.primed = false;
let s = self.settings;
let _ = self.set_settings(s);
}
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
let s = self.settings;
self.drive += self.smooth * (db_to_gain(s.drive) - self.drive);
self.ceiling += self.smooth * (db_to_gain(s.ceiling) - self.ceiling);
self.bypass += self.fade * (if s.bypass { 1. } else { 0. } - self.bypass);
let (c, k, drive) = (self.ceiling, self.coefs, self.drive);
let mut out = [0.; 2];
let mut point = [f32::MAX, f32::MIN, 0., 0.];
let n = self.channel[0].dry.len();
for (ch, &input) in x.iter().enumerate().take(self.channels) {
let chan = &mut self.channel[ch];
let driven = input * drive;
self.gather.input = self.gather.input.max(driven.abs());
let up = chan.os.up(driven);
let mut picked = [0.; FACTOR];
for (j, &v) in up.iter().enumerate() {
let low = lr4(&mut chan.low, &k.low, v);
let high = lr4(&mut chan.high, &k.high, v);
// The sub's held peak decides the top's room.
let a = low.abs();
if a >= chan.env {
chan.env = a;
chan.hold_left = k.hold;
} else if chan.hold_left > 0 {
chan.hold_left -= 1;
} else {
chan.env = a.max(chan.env * k.release);
}
let room = (c - chan.env).max(0.);
let top = shape(high, room, k.knee);
let reference = low + high;
let mut y = reference + chan.error.process(&k.high, top - high);
for p in &mut chan.passes {
y += p.process(&k.high, y.clamp(-c, c) - y);
}
// What the passes leave over the ceiling is small; clipping it here, still
// oversampled, leaves the safety clip after the downsampler almost idle.
let y = y.clamp(-c, c);
let plain = shape(reference, c, k.knee);
let lq = lr4(&mut chan.meter_quench, &k.low, y - reference);
let lp = lr4(&mut chan.meter_plain, &k.low, plain - reference);
self.energy_quench += self.meter * (lq * lq - self.energy_quench);
self.energy_plain += self.meter * (lp * lp - self.energy_plain);
self.energy_sub += self.meter * (low * low - self.energy_sub);
self.gather.sub = self.gather.sub.max(a);
if high.abs() > room {
let ratio = high.abs() / room.max(c * 1e-3);
self.gather.top = self.gather.top.max(ratio);
}
picked[j] = match monitor {
Monitor::Quench => y,
Monitor::Plain => plain,
Monitor::Delta => y - reference,
};
if ch == 0 {
point[0] = point[0].min(picked[j]);
point[1] = point[1].max(picked[j]);
point[2] = low;
point[3] = room;
}
}
let mut o = chan.os.down(picked);
if monitor != Monitor::Delta && o.abs() > c {
self.gather.safety = self.gather.safety.max(o.abs() / c);
o = o.clamp(-c, c);
}
let dry = chan.dry[(self.dry_at + 1) % n];
chan.dry[self.dry_at] = input;
o = if self.bypass >= 1. - 1e-6 {
dry
} else {
o + self.bypass * (dry - o)
};
self.gather.output = self.gather.output.max(o.abs());
out[ch] = o;
}
self.dry_at = (self.dry_at + 1) % n;
self.scope[self.scope_at] = point;
self.scope_at = (self.scope_at + 1) % SCOPE_LEN;
out
}
/// Readings since the last call. Never allocates.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let g = std::mem::take(&mut self.gather);
let db = |v: f32| if v > 0. { gain_to_db(v) } else { SILENT };
let relative = |e: f32| {
if self.energy_sub > SUB_FLOOR {
10. * (e.max(1e-20) / self.energy_sub).log10()
} else {
f32::NAN
}
};
let mut f = AnalyzerFrame {
input_peak: db(g.input),
output_peak: db(g.output),
sub_peak: db(g.sub),
top_clip: if g.top > 1. { gain_to_db(g.top) } else { 0. },
safety: if g.safety > 1. {
gain_to_db(g.safety)
} else {
0.
},
low_quench: relative(self.energy_quench),
low_plain: relative(self.energy_plain),
ceiling: self.settings.ceiling,
latency: self.latency_samples(),
sample_rate: self.rate,
..AnalyzerFrame::default()
};
for i in 0..SCOPE {
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
let mut last = [0.; 4];
for j in 0..SCOPE_STEP {
let v = self.scope[(self.scope_at + i * SCOPE_STEP + j) % SCOPE_LEN];
lo = lo.min(v[0]);
hi = hi.max(v[1]);
last = v;
}
f.scope_min[i] = if lo <= hi { lo } else { 0. };
f.scope_max[i] = if lo <= hi { hi } else { 0. };
f.scope_sub[i] = last[2];
f.scope_room[i] = last[3];
}
f
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
use serde::{Deserialize, Serialize};
pub const DRIVE_MAX: f32 = 24.;
pub const CEILING_MIN: f32 = -12.;
pub const SPLIT_MIN: f32 = 40.;
pub const SPLIT_MAX: f32 = 300.;
pub const HOLD_MIN: f32 = 10.;
pub const HOLD_MAX: f32 = 300.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
/// Gain into the clipper.
pub drive: f32,
/// Highest output peak, in dBFS.
pub ceiling: f32,
/// Everything below this frequency is the sub: it passes without clipping.
pub split: f32,
/// How long the sub's peak keeps its headroom reserved. Longer than half a period of the
/// lowest note keeps the top's clip level steady through the sub's cycle.
pub hold: f32,
/// Knee of the top's clipper: 0 % is a hard clip, 100 % a soft curve from the start.
pub soft: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
drive: 0.,
ceiling: -0.3,
split: 120.,
hold: 60.,
soft: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [self.drive, self.ceiling, self.split, self.hold, self.soft]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (0.0..=DRIVE_MAX).contains(&self.drive)
&& (CEILING_MIN..=0.).contains(&self.ceiling)
&& (SPLIT_MIN..=SPLIT_MAX).contains(&self.split)
&& (HOLD_MIN..=HOLD_MAX).contains(&self.hold)
&& (0.0..=100.).contains(&self.soft);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+300
View File
@@ -0,0 +1,300 @@
use quench_core::{shape, Engine, Monitor, Settings};
use std::f64::consts::TAU;
const RATE: f32 = 48000.;
/// Partials of the top: not harmonics of the sub, so their clipping products can be told
/// apart from the sub's own harmonics.
const TOPS: [f64; 5] = [437., 611., 1013., 1597., 2399.];
fn top(t: f64) -> f64 {
TOPS.iter()
.enumerate()
.map(|(i, f)| (TAU * f * t + i as f64).sin())
.sum::<f64>()
/ 4.2
}
/// Mono render of `seconds` of `signal`, returned as the left channel.
fn render(
engine: &mut Engine,
seconds: f32,
monitor: Monitor,
signal: impl Fn(f64) -> f64,
) -> Vec<f32> {
let n = (seconds * RATE) as usize;
(0..n)
.map(|i| {
let v = signal(i as f64 / RATE as f64) as f32;
engine.process_sample([v, v], monitor)[0]
})
.collect()
}
fn engine(s: Settings) -> Engine {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e
}
/// Amplitude of `hz` over a whole number of its periods at the end of `x`.
fn amplitude(x: &[f32], hz: f64) -> f64 {
let n = (RATE as f64 / hz * (0.5 * hz).floor()) as usize;
let x = &x[x.len() - n..];
let (mut re, mut im) = (0., 0.);
for (i, &v) in x.iter().enumerate() {
let a = TAU * hz * i as f64 / RATE as f64;
re += v as f64 * a.cos();
im += v as f64 * a.sin();
}
2. * (re * re + im * im).sqrt() / n as f64
}
/// Hann-windowed spectrum magnitude of the last half second, 2 Hz bins up to `top_bin`.
fn spectrum(x: &[f32], top_bin: usize) -> Vec<f64> {
let n = RATE as usize / 2;
let x = &x[x.len() - n..];
let w: Vec<f64> = (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f64 / n as f64).cos())
.collect();
let norm: f64 = w.iter().sum();
(0..=top_bin)
.map(|b| {
let (mut re, mut im) = (0., 0.);
for i in 0..n {
let a = TAU * b as f64 * i as f64 / n as f64;
re += x[i] as f64 * w[i] * a.cos();
im += x[i] as f64 * w[i] * a.sin();
}
2. * (re * re + im * im).sqrt() / norm
})
.collect()
}
/// Everything below 110 Hz except the 50 Hz sub, against the sub, in dB.
fn low_junk(x: &[f32]) -> f64 {
let s = spectrum(x, 55);
let junk: f64 = s
.iter()
.enumerate()
.filter(|(b, _)| !(22..=28).contains(b) && *b > 0)
.map(|(_, v)| v * v)
.sum();
let sub: f64 = s[22..=28].iter().map(|v| v * v).sum();
10. * (junk / sub).log10()
}
fn db(r: f64) -> f64 {
20. * r.log10()
}
#[test]
fn below_the_ceiling_the_signal_passes_unchanged() {
let mut e = engine(Settings::default());
let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 0.2 * (TAU * 1000. * t).sin();
let out = render(&mut e, 1., Monitor::Quench, signal);
for (hz, a) in [(50., 0.3), (1000., 0.2)] {
let got = db(amplitude(&out, hz) / a);
assert!(got.abs() < 0.02, "{hz} Hz: {got:.3} dB");
}
let f = e.take_analyzer();
assert_eq!(f.top_clip, 0.);
assert_eq!(f.safety, 0.);
let mut e = engine(Settings::default());
let delta = render(&mut e, 0.2, Monitor::Delta, signal);
assert!(delta.iter().all(|v| v.abs() < 1e-4));
}
#[test]
fn the_output_never_passes_the_ceiling() {
let mut seed = 1u32;
let noise: Vec<f64> = (0..RATE as usize)
.map(|_| {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
(seed >> 8) as f64 / (1u32 << 24) as f64 * 2. - 1.
})
.collect();
for ceiling in [-6., -0.3, 0.] {
for soft in [0., 50., 100.] {
for monitor in [Monitor::Quench, Monitor::Plain] {
let mut e = engine(Settings {
drive: 24.,
ceiling,
soft,
..Settings::default()
});
let out = render(&mut e, 1., monitor, |t| {
let i = ((t * RATE as f64) as usize).min(noise.len() - 1);
0.4 * (TAU * 41. * t).sin() + 0.3 * top(t) + 0.2 * noise[i]
});
let limit = 10f32.powf(ceiling / 20.) * (1. + 1e-6);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(
peak <= limit,
"{ceiling} dB, soft {soft}, {monitor:?}: {peak}"
);
}
}
}
}
#[test]
fn the_sub_keeps_its_level_and_the_low_band_stays_clean() {
// A quiet sub under a top that peaks far over the ceiling: the hard case for a clipper.
let signal = |t: f64| 0.3 * (TAU * 50. * t).sin() + 2. * top(t);
let mut results = Vec::new();
for monitor in [Monitor::Quench, Monitor::Plain] {
let mut e = engine(Settings::default());
let out = render(&mut e, 1.5, monitor, signal);
let sub = db(amplitude(&out, 50.) / 0.3);
results.push((sub, low_junk(&out)));
}
let [(q_sub, q_junk), (p_sub, p_junk)] = [results[0], results[1]];
assert!(q_sub.abs() < 0.2, "Quench changes the sub by {q_sub:.2} dB");
assert!(
p_sub < -0.8,
"a plain clip should squash the sub: {p_sub:.2} dB"
);
assert!(
q_junk < p_junk - 10.,
"low-band products: Quench {q_junk:.1} dB, plain {p_junk:.1} dB"
);
}
#[test]
fn the_meter_shows_how_much_less_the_low_band_changes() {
let mut e = engine(Settings::default());
render(&mut e, 1.5, Monitor::Quench, |t| {
0.3 * (TAU * 50. * t).sin() + 2. * top(t)
});
let f = e.take_analyzer();
assert!(
f.low_quench < f.low_plain - 10.,
"Quench {:.1} dB, plain {:.1} dB",
f.low_quench,
f.low_plain
);
assert!(f.top_clip > 6.);
// The oversampled clip leaves the safety clip after the downsampler almost idle.
assert!(f.safety < 0.2, "{}", f.safety);
let mut e = engine(Settings::default());
render(&mut e, 0.5, Monitor::Quench, |t| {
0.5 * (TAU * 1000. * t).sin()
});
assert!(
e.take_analyzer().low_quench.is_nan(),
"no sub, nothing to compare"
);
}
#[test]
fn a_sub_over_the_ceiling_leaves_no_room_and_is_still_limited() {
let mut e = engine(Settings {
ceiling: -6.,
..Settings::default()
});
let out = render(&mut e, 1., Monitor::Quench, |t| {
0.8 * (TAU * 50. * t).sin() + 0.3 * top(t)
});
let f = e.take_analyzer();
assert!(f.sub_peak > -6., "{}", f.sub_peak);
assert!(f.safety > 0.);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(peak <= 0.5012 * (1. + 1e-6));
let room = f.scope_room.iter().fold(0f32, |m, v| m.max(*v));
assert_eq!(room, 0., "the held sub peak is over the ceiling");
}
#[test]
fn the_held_peak_keeps_the_tops_room_steady_through_the_cycle() {
let room_ripple = |hold: f32, hz: f64| {
let mut e = engine(Settings {
hold,
..Settings::default()
});
render(&mut e, 1., Monitor::Quench, |t| {
0.5 * (TAU * hz * t).sin() + 0.3 * top(t)
});
let f = e.take_analyzer();
let (lo, hi) = f
.scope_room
.iter()
.fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v)));
(hi - lo) / hi
};
// 30 Hz: the sub peaks every 16.7 ms.
let held = room_ripple(60., 30.);
let short = room_ripple(10., 30.);
assert!(held < 0.01, "{held}");
assert!(short > 0.1, "{short}");
}
#[test]
fn bypass_is_the_input_delayed_by_the_latency() {
let mut e = engine(Settings {
bypass: true,
drive: 12.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let x: Vec<f32> = (0..4800)
.map(|i| (0.9 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32 + 0.5)
.collect();
let out: Vec<f32> = x
.iter()
.map(|&v| e.process_sample([v, v], Monitor::Quench)[0])
.collect();
for i in latency..x.len() {
assert_eq!(out[i], x[i - latency]);
}
}
#[test]
fn mono_works_and_the_shape_never_passes_its_limit() {
let mut e = Engine::new(44100., 1).unwrap();
e.set_settings(Settings {
drive: 12.,
..Settings::default()
})
.unwrap();
for i in 0..44100 {
let v = (TAU * 55. * i as f64 / 44100.).sin() as f32;
let o = e.process_sample([v, 0.], Monitor::Quench);
assert!(o[0].abs() <= 0.9661 && o[1] == 0.);
}
for knee in [0., 0.3, 1.] {
for h in [-5., -1., -0.2, 0., 0.5, 0.99, 3.] {
let y = shape(h, 0.8, knee);
assert!(y.abs() <= 0.8 && y * h >= 0.);
}
}
assert_eq!(shape(0.5, 0.8, 0.), 0.5);
assert_eq!(shape(0.5, 0., 0.), 0.);
}
#[test]
fn settings_are_validated_and_serialise() {
assert!(Settings::default().validate().is_ok());
for bad in [
Settings {
drive: -1.,
..Settings::default()
},
Settings {
ceiling: 0.5,
..Settings::default()
},
Settings {
split: 20.,
..Settings::default()
},
Settings {
hold: f32::NAN,
..Settings::default()
},
] {
assert!(bad.validate().is_err());
assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err());
}
let s = Settings {
drive: 9.,
soft: 40.,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"drive":0,"oops":1}"#).is_err());
}
+65
View File
@@ -0,0 +1,65 @@
use quench_core::{Engine, Monitor, Settings};
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_settings_monitors_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings {
drive: 12.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
let monitors = [Monitor::Quench, Monitor::Plain, Monitor::Delta];
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
let step = i / 4800;
settings.split = 60. + 20. * (step % 6) as f32;
settings.hold = 20. + 40. * (step % 3) as f32;
settings.soft = 25. * (step % 5) as f32;
settings.ceiling = -3. * (step % 3) as f32;
settings.bypass = step % 7 == 6;
engine.set_settings(settings).unwrap();
}
let t = i as f32 / 48000.;
let x = (std::f32::consts::TAU * 45. * t).sin() * 0.5
+ (std::f32::consts::TAU * 733. * t).sin() * 0.5;
std::hint::black_box(engine.process_sample([x, -x], monitors[(i / 9600) % 3]));
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 = "quench"
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" }
quench-core = { path = "../core" }
quench-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"
+204
View File
@@ -0,0 +1,204 @@
//! 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::{QuenchParams, FLOATS};
use quench_core::{AnalyzerFrame, Engine, Monitor};
use std::sync::{
atomic::{AtomicU8, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
/// The editor's listen choice: 0 Quench, 1 Plain, 2 Delta.
monitor: AtomicU8,
}
fn encode(m: Monitor) -> u8 {
match m {
Monitor::Quench => 0,
Monitor::Plain => 1,
Monitor::Delta => 2,
}
}
fn decode(v: u8) -> Monitor {
match v {
1 => Monitor::Plain,
2 => Monitor::Delta,
_ => Monitor::Quench,
}
}
struct EditorData {
ui: quench_ui::State,
gestures: [bool; FLOATS],
}
pub struct Quench {
params: Arc<QuenchParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Quench {
fn default() -> Self {
Self {
params: Arc::new(QuenchParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
monitor: AtomicU8::new(0),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Quench {
const NAME: &'static str = "Quench";
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 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 shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: quench_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, data| {
data.ui.monitor = Monitor::Quench;
quench_ui::style(ctx);
},
move |ctx, setter, data| {
while let Some(frame) = shared.analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("quench-editor")
.min_size(egui::vec2(
quench_ui::WIDTH as f32 - 80.,
quench_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
quench_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The monitor is a listen choice only: never saved and never left on after
// the editor closes.
shared
.monitor
.store(encode(data.ui.monitor), Ordering::Release);
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;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.shared.monitor.store(0, Ordering::Release);
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,
_: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let monitor = if self.params.editor.is_open() {
decode(self.shared.monitor.load(Ordering::Acquire))
} else {
Monitor::Quench
};
for mut sample in buffer.iter_samples() {
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x, monitor);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
let _ = self.shared.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The oversampling filters must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Quench {
const CLAP_ID: &'static str = "io.narl.bass-foundry.quench";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Clips the top as hard as you like while the sub passes clean");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Limiter,
ClapFeature::Mastering,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Quench);
+108
View File
@@ -0,0 +1,108 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use quench_core::{Settings, CEILING_MIN, DRIVE_MAX, HOLD_MAX, HOLD_MIN, SPLIT_MAX, SPLIT_MIN};
use std::sync::Arc;
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 skewed(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(
name,
v,
FloatRange::Skewed {
min,
max,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 5;
#[derive(Params)]
pub struct QuenchParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "drive"]
pub drive: FloatParam,
#[id = "ceiling"]
pub ceiling: FloatParam,
#[id = "split"]
pub split: FloatParam,
#[id = "hold"]
pub hold: FloatParam,
#[id = "soft"]
pub soft: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for QuenchParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(quench_ui::WIDTH, quench_ui::HEIGHT),
drive: linear("Drive", d.drive, 0., DRIVE_MAX, " dB"),
ceiling: linear("Ceiling", d.ceiling, CEILING_MIN, 0., " dB"),
split: skewed("Split", d.split, SPLIT_MIN, SPLIT_MAX, " Hz"),
hold: skewed("Hold", d.hold, HOLD_MIN, HOLD_MAX, " ms"),
soft: linear("Soft", d.soft, 0., 100., " %"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl QuenchParams {
pub fn settings(&self) -> Settings {
Settings {
drive: self.drive.value(),
ceiling: self.ceiling.value(),
split: self.split.value(),
hold: self.hold.value(),
soft: self.soft.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.drive,
&self.ceiling,
&self.split,
&self.hold,
&self.soft,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.drive, s.ceiling, s.split, s.hold, s.soft]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.bypass != old.bypass {
setter.begin_set_parameter(&self.bypass);
setter.set_parameter(&self.bypass, new.bypass);
setter.end_set_parameter(&self.bypass);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 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 = &quench::clap_entry;
assert!(entry.init.unwrap()(c"quench.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"Quench");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.quench"
);
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;
envelope.params.insert("soft".into(), ParamValue::F32(40.));
envelope.params.insert("hold".into(), ParamValue::F32(100.));
envelope.params.insert("split".into(), ParamValue::F32(90.));
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;
// Only the 4× oversampling filters: under a millisecond at every rate.
assert_eq!(delay, 37);
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 well under the ceiling passes unchanged.
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 + 4800 {
// The tone's onset rings in the filters, but never past the ceiling.
assert!(left[i].abs() <= 0.9661, "{}", left[i]);
} 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 = "quench-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
quench-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+327
View File
@@ -0,0 +1,327 @@
//! Quench 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 quench_core::{
AnalyzerFrame, Monitor, Settings, CEILING_MIN, DRIVE_MAX, HOLD_MAX, HOLD_MIN, SCOPE, SPLIT_MAX,
SPLIT_MIN,
};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 640;
pub struct State {
pub analyzer: AnalyzerFrame,
/// What the output plays. Not saved: the wrapper resets it when the editor opens.
pub monitor: Monitor,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
monitor: Monitor::Quench,
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Put Quench last on the master or the bass bus. Raise Drive until the top clips as hard as you want.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 72., ACCENT);
hint(&r, state, help);
});
}
/// The selected output with the ceiling, the sub band, and the room the sub leaves the top.
fn scope(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
"OUTPUT · SUB · ROOM FOR THE TOP",
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
format!(
"{:.0} ms",
SCOPE as f32 * 8. / frame.sample_rate.max(1.) * 1000.
),
FontId::proportional(11.),
MUTED,
);
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(12., 28.),
rect.right_bottom() - Vec2::new(12., 10.),
);
let mid = area.center().y;
let scale = area.height() * 0.5 / 1.08;
let y = |v: f32| mid - v.clamp(-1.08, 1.08) * scale;
let x = |i: usize| area.left() + area.width() * i as f32 / (SCOPE - 1) as f32;
p.line_segment(
[Pos2::new(area.left(), mid), Pos2::new(area.right(), mid)],
Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)),
);
let c = 10f32.powf(settings.ceiling / 20.);
for v in [c, -c] {
p.line_segment(
[Pos2::new(area.left(), y(v)), Pos2::new(area.right(), y(v))],
Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.6)),
);
}
p.text(
Pos2::new(area.right() - 4., y(c) - 3.),
egui::Align2::RIGHT_BOTTOM,
format!("CEILING {:.1} dB", settings.ceiling),
FontId::proportional(10.),
ACCENT,
);
for i in 0..SCOPE {
let (lo, hi) = (frame.scope_min[i], frame.scope_max[i]);
let top = y(hi);
let bottom = y(lo).max(top + 1.);
p.line_segment(
[Pos2::new(x(i), top), Pos2::new(x(i), bottom)],
Stroke::new(2.0_f32, STEEL.gamma_multiply(0.55)),
);
}
// Where the output may go: the sub plus or minus the top's room.
for sign in [1., -1.] {
let line: Vec<Pos2> = (0..SCOPE)
.map(|i| Pos2::new(x(i), y(frame.scope_sub[i] + sign * frame.scope_room[i])))
.collect();
p.add(egui::Shape::line(line, Stroke::new(1.0_f32, MUTED)));
}
let sub: Vec<Pos2> = (0..SCOPE)
.map(|i| Pos2::new(x(i), y(frame.scope_sub[i])))
.collect();
p.add(egui::Shape::line(sub, Stroke::new(2.0_f32, FOCUS)));
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "QUENCH", "SUB-SAFE CLIPPER", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the input, delayed by the latency.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (monitor, label, text) in [
(
Monitor::Delta,
"DELTA",
"Hear what Quench takes away. Not saved.",
),
(
Monitor::Plain,
"PLAIN",
"Hear a plain clipper at the same settings, to compare the low end. Not saved.",
),
(Monitor::Quench, "QUENCH", "Hear Quench."),
] {
let r = ui
.selectable_label(state.monitor == monitor, RichText::new(label).size(13.))
.on_hover_text(text);
if r.clicked() {
state.monitor = monitor;
}
}
caption(ui, "LISTEN");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(250.);
ui.set_min_height(TOP);
ui.vertical(|ui| readings(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
scope(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 330.;
fn readings(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "LOW END CHANGED BY");
let has_sub = frame.low_quench.is_finite();
ui.label(
RichText::new(if has_sub {
format!("{:.0} dB", frame.low_quench)
} else {
"—".into()
})
.font(FontId::new(44., egui::FontFamily::Name("medium".into())))
.color(if has_sub { TEXT } else { DIM }),
);
ui.label(
RichText::new(if has_sub {
format!("plain clip: {:.0} dB", frame.low_plain)
} else {
"no sub playing".into()
})
.monospace()
.size(13.)
.color(MUTED),
);
ui.add_space(12.);
readout(
ui,
"TOP CLIPPED BY",
if frame.top_clip >= 40. {
"no room left".into()
} else if frame.top_clip > 0. {
format!("{:.1} dB", frame.top_clip)
} else {
"not clipping".into()
},
ACCENT,
);
let room = settings.ceiling - frame.sub_peak;
readout(
ui,
"SUB PEAK · ROOM LEFT",
if frame.sub_peak > -119. {
format!("{:.1} dBFS · {:.1} dB", frame.sub_peak, room.max(0.))
} else {
"—".into()
},
FOCUS,
);
if frame.sub_peak > settings.ceiling - 1. {
ui.label(
RichText::new(
"The sub alone reaches the ceiling: the top has no room and the safety clip \
cuts the sub. Lower Drive or the sub.",
)
.size(12.)
.color(ACCENT),
);
ui.add_space(6.);
}
ui.add_space(4.);
meter(ui, "IN", frame.input_peak, STEEL, 150.);
meter(ui, "OUT", frame.output_peak, ACCENT, 150.);
ui.add_space(6.);
ui.label(
RichText::new(format!(
"safety clip {} · {:.1} ms latency",
if frame.safety > 0. {
format!("{:.2} dB", frame.safety)
} else {
"idle".into()
},
frame.latency as f32 / frame.sample_rate.max(1.) * 1000.
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(5, |c| {
knob(
&mut c[0],
state,
"Drive",
&mut settings.drive,
0. ..=DRIVE_MAX,
d.drive,
" dB",
"Gain into the clipper. The sub rises with it: watch the room it leaves.",
);
knob(
&mut c[1],
state,
"Ceiling",
&mut settings.ceiling,
CEILING_MIN..=0.,
d.ceiling,
" dB",
"Highest output peak. The sub keeps its level; the top gets what is left.",
);
knob(
&mut c[2],
state,
"Split",
&mut settings.split,
SPLIT_MIN..=SPLIT_MAX,
d.split,
" Hz",
"Below this the sub passes without clipping. Set it above the highest sub note, \
or at Anvil's Split.",
);
knob(
&mut c[3],
state,
"Hold",
&mut settings.hold,
HOLD_MIN..=HOLD_MAX,
d.hold,
" ms",
"How long the sub's peak keeps its room. Longer than half a period of the lowest \
note keeps the top from pumping with the sub.",
);
knob(
&mut c[4],
state,
"Soft",
&mut settings.soft,
0. ..=100.,
d.soft,
" %",
"Knee of the top's clip. 0 % is a hard clip, 100 % a soft curve.",
);
});
}
+97 -49
View File
@@ -7,7 +7,7 @@ mod settings;
use common_dsp::meter::{HarmonicMeter, HARMONICS};
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, PitchFollower, Tracker, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi};
pub use settings::{Curve, Settings, Source};
@@ -127,15 +127,14 @@ impl Smoothed {
}
}
pub struct Engine {
/// The tracked distortion without its tracker: driven by note events aligned with its input.
/// Its latency is the oversampling filters'.
pub struct Module {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
dry_at: usize,
tracker: Tracker,
sounding: bool,
pitch: PitchFollower,
hz_coef: f32,
countdown: usize,
@@ -162,7 +161,7 @@ pub struct Engine {
fresh: bool,
}
impl Engine {
impl Module {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
@@ -170,19 +169,14 @@ impl Engine {
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let smooth = coefficient(rate, 10.);
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency: lookahead + Oversampler::latency(),
delay: vec![[0.; 2]; lookahead + 1],
delay_at: 0,
dry_at: 0,
tracker,
sounding: false,
pitch: PitchFollower::default(),
hz_coef: coefficient(rate, 5.),
countdown: 0,
@@ -215,10 +209,6 @@ impl Engine {
e.fresh = true;
Ok(e)
}
/// Lookahead plus the oversampling filters.
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
@@ -227,7 +217,6 @@ impl Engine {
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, true);
if std::mem::take(&mut self.fresh) {
self.drive.value = db_to_gain(s.drive);
self.bias.value = s.bias * 0.008;
@@ -239,8 +228,7 @@ impl Engine {
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.sounding = false;
for c in &mut self.channel {
c.reset();
}
@@ -253,12 +241,6 @@ impl Engine {
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);
}
fn retune(&mut self, hz: f32) {
let s = &self.settings;
let limit = 0.45 * self.rate;
@@ -280,26 +262,7 @@ impl Engine {
SvfCoef::IDENTITY
};
}
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]
};
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
let (pitch, level) = (&mut self.pitch, &mut self.level);
self.tracker.process(mono, |e| {
pitch.event(false, e);
level.event(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];
fn run(&mut self, y: [f32; 2]) -> [f32; 2] {
let (hz, jump) = self.pitch.step(self.hz_coef);
if jump || self.countdown == 0 {
self.retune(hz);
@@ -371,7 +334,7 @@ impl Engine {
let v = (mix * wet[c] * auto + (1. - mix) * dry[c]) * output;
out[c] = v * (1. - bypass) + dry[c] * bypass;
}
let tone = if self.tracker.sounding() { hz } else { 0. };
let tone = if self.sounding { hz } else { 0. };
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
@@ -381,11 +344,11 @@ impl Engine {
self.meter_out.push(o, tone);
out
}
/// Editor data. `latency` is left at 0 for the owner to fill in.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let hz = self.pitch.hz();
let mut f = AnalyzerFrame {
hz,
latency: self.latency as u32,
sample_rate: self.rate,
shaper_db: gain_to_db(self.shaper_peak),
normalize_db: self.norm_db.value,
@@ -397,9 +360,94 @@ impl Engine {
.copy_from_slice(&self.meter_in.levels()[..SHOWN.min(HARMONICS)]);
f.output
.copy_from_slice(&self.meter_out.levels()[..SHOWN.min(HARMONICS)]);
if self.tracker.sounding() && hz > 0. {
if self.sounding && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
impl Tracked for Module {
fn latency(&self) -> usize {
Oversampler::latency()
}
fn ahead(&mut self, mono: f32) {
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
}
fn event(&mut self, e: NoteEvent) {
self.pitch.event(false, e);
self.level.event(e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
self.run(y)
}
}
/// Standalone Temper: its own tracker in front of the [`Module`].
pub struct Engine {
channels: usize,
lookahead: Lookahead,
module: Module,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
let module = Module::new(rate, channels)?;
let mut e = Self {
channels,
lookahead: Lookahead::new(rate, TrackerConfig::BASS),
module,
};
// The module already holds the defaults; only the tracker still needs them.
e.set_tracker(&Settings::default());
Ok(e)
}
/// Lookahead plus the oversampling filters.
pub fn latency_samples(&self) -> u32 {
(self.lookahead.latency() + self.module.latency()) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.module.settings()
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
self.module.set_settings(s)?;
self.set_tracker(&s);
Ok(())
}
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, true);
}
pub fn reset(&mut self) {
self.lookahead.reset();
self.module.reset();
}
pub fn note_on(&mut self, note: u8) {
self.lookahead.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.lookahead.tracker.note_off(note);
}
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]
};
self.lookahead.process(x, mono, &mut self.module)
}
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
AnalyzerFrame {
latency: self.latency_samples(),
..self.module.take_analyzer()
}
}
}
+20 -10
View File
@@ -233,14 +233,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
if r.clicked() {
settings.bypass = !settings.bypass;
}
let r = ui
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
.on_hover_text(
"Match output loudness to the input, so Drive changes character, not level.",
);
if r.clicked() {
settings.auto_gain = !settings.auto_gain;
}
toggles(ui, settings);
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.));
@@ -250,6 +243,13 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
caption(ui, "PITCH FROM");
});
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Everything under the header: the standalone editor and Forge both draw it.
pub fn body(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -294,8 +294,6 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 290.;
@@ -411,3 +409,15 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Output trim.");
});
}
/// Header switches besides Bypass and the pitch source.
pub fn toggles(ui: &mut egui::Ui, settings: &mut Settings) {
let r = ui
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
.on_hover_text(
"Match output loudness to the input, so Drive changes character, not level.",
);
if r.clicked() {
settings.auto_gain = !settings.auto_gain;
}
}
+69
View File
@@ -60,6 +60,24 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "ballast-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
]
[[package]]
name = "ballast-ui"
version = "0.1.0"
dependencies = [
"ballast-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -171,12 +189,16 @@ version = "0.1.0"
dependencies = [
"anvil-core",
"anvil-ui",
"ballast-core",
"ballast-ui",
"braze-core",
"braze-ui",
"common-dsp",
"crucible-core",
"crucible-ui",
"egui",
"forge-core",
"forge-ui",
"image",
"knurl-core",
"knurl-ui",
@@ -186,6 +208,8 @@ dependencies = [
"patina-ui",
"planish-core",
"planish-ui",
"quench-core",
"quench-ui",
"rivet-app",
"rivet-core",
"rivet-midi",
@@ -257,6 +281,33 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "forge-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"knurl-core",
"lathe-core",
"serde",
"temper-core",
]
[[package]]
name = "forge-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"forge-core",
"knurl-core",
"knurl-ui",
"lathe-core",
"lathe-ui",
"temper-core",
"temper-ui",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -513,6 +564,24 @@ version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quench-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
]
[[package]]
name = "quench-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"quench-core",
]
[[package]]
name = "quote"
version = "1.0.47"
+6
View File
@@ -29,5 +29,11 @@ planish-core = { path = "../../plugins/planish/core" }
planish-ui = { path = "../../plugins/planish/ui" }
braze-core = { path = "../../plugins/braze/core" }
braze-ui = { path = "../../plugins/braze/ui" }
quench-core = { path = "../../plugins/quench/core" }
quench-ui = { path = "../../plugins/quench/ui" }
ballast-core = { path = "../../plugins/ballast/core" }
ballast-ui = { path = "../../plugins/ballast/ui" }
forge-core = { path = "../../plugins/forge/core" }
forge-ui = { path = "../../plugins/forge/ui" }
weld-core = { path = "../../plugins/weld/core" }
weld-ui = { path = "../../plugins/weld/ui" }
+112 -6
View File
@@ -12,13 +12,13 @@ fn main() {
.unwrap_or("rivet");
if ![
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish",
"braze",
"braze", "quench", "ballast", "forge",
]
.contains(&plugin)
{
eprintln!(
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish \
or braze"
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, \
braze, quench, ballast or forge"
);
std::process::exit(2);
}
@@ -32,7 +32,16 @@ fn main() {
let knurl = plugin == "knurl";
let planish = plugin == "planish";
let braze = plugin == "braze";
let (width, height) = if braze {
let quench = plugin == "quench";
let ballast = plugin == "ballast";
let forge = plugin == "forge";
let (width, height) = if forge {
(forge_ui::WIDTH, forge_ui::HEIGHT)
} else if ballast {
(ballast_ui::WIDTH, ballast_ui::HEIGHT)
} else if quench {
(quench_ui::WIDTH, quench_ui::HEIGHT)
} else if braze {
(braze_ui::WIDTH, braze_ui::HEIGHT)
} else if planish {
(planish_ui::WIDTH, planish_ui::HEIGHT)
@@ -189,6 +198,39 @@ fn main() {
}
knurl_state.accept_frame(engine.take_analyzer());
}
let mut forge_settings = forge_core::Settings::default();
forge_settings.lathe.gains[1] = -9.;
forge_settings.lathe.gains[3] = 4.;
forge_settings.temper.drive = 9.;
forge_settings.order = [
forge_core::Stage::Knurl,
forge_core::Stage::Lathe,
forge_core::Stage::Temper,
];
let mut forge_state = forge_ui::State::default();
forge_state.page = match args
.iter()
.position(|a| a == "--page")
.map(|i| args[i + 1].as_str())
{
Some("temper") => forge_core::Stage::Temper,
Some("knurl") => forge_core::Stage::Knurl,
_ => forge_core::Stage::Lathe,
};
if forge {
// An F1 saw bass through Knurl's Fry pattern first: Lathe after it still follows F1.
let mut engine = forge_core::Engine::new(48000., 2).unwrap();
engine.set_settings(forge_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
let x = (saw.sample() * 2.2).tanh() * 0.5;
engine.process_sample([x, x]);
if i % 1600 == 0 {
forge_state.accept_frame(engine.take_analyzer());
}
}
forge_state.accept_frame(engine.take_analyzer());
}
let mut planish_settings = planish_core::Settings {
depth: 80.,
gains: [0., 2., 3., -2.],
@@ -229,6 +271,58 @@ fn main() {
}
braze_state.accept_frame(engine.take_analyzer());
}
let mut quench_settings = quench_core::Settings {
drive: 9.,
..Default::default()
};
let mut quench_state = quench_ui::State::default();
if quench {
// A 49 Hz sub under a distorted saw top two octaves up, driven 9 dB into the clipper.
let mut engine = quench_core::Engine::new(48000., 2).unwrap();
engine.set_settings(quench_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 196.);
for i in 0..48000 {
let t = i as f32 / 48000.;
let sub = 0.18 * (std::f32::consts::TAU * 49. * t).sin();
let top = (saw.sample() * 3.).tanh() * 0.3;
engine.process_sample([sub + top, sub + top], quench_core::Monitor::Quench);
if i % 1600 == 0 {
quench_state.accept_frame(engine.take_analyzer());
}
}
quench_state.accept_frame(engine.take_analyzer());
}
let mut ballast_settings = ballast_core::Settings {
segment: ballast_core::Segment::Grid,
target: -16.,
..Default::default()
};
let mut ballast_state = ballast_ui::State::default();
if ballast {
// A switch-up every quarter at 140 BPM, cycling four patches of different loudness.
let mut engine = ballast_core::Engine::new(48000., 2).unwrap();
engine.set_settings(ballast_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
let quarter = 48000. * 60. / 140.;
for i in 0..48000 * 9 {
if i % 512 == 0 {
engine.set_transport(Some(140.), Some(i as f64 / 48000. * 140. / 60.));
}
let part = (i as f32 / quarter) as usize;
let s = saw.sample();
let x = match part % 4 {
0 => (s * 4.).tanh() * 0.6,
1 => s * 0.15,
2 => (s * 2.).tanh() * 0.35,
_ => (s * 8.).tanh() * 0.9,
};
engine.process_sample([x, x]);
if i % 1600 == 0 {
ballast_state.accept_frame(engine.take_analyzer());
}
}
ballast_state.accept_frame(engine.take_analyzer());
}
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
@@ -358,7 +452,13 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if braze {
if forge {
forge_ui::style(&ctx);
} else if ballast {
ballast_ui::style(&ctx);
} else if quench {
quench_ui::style(&ctx);
} else if braze {
braze_ui::style(&ctx);
} else if planish {
planish_ui::style(&ctx);
@@ -399,7 +499,13 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if braze {
if forge {
forge_ui::show(ui, &mut forge_settings, &mut forge_state);
} else if ballast {
ballast_ui::show(ui, &mut ballast_settings, &mut ballast_state);
} else if quench {
quench_ui::show(ui, &mut quench_settings, &mut quench_state);
} else if braze {
braze_ui::show(ui, &mut braze_settings, &mut braze_state);
} else if planish {
planish_ui::show(ui, &mut planish_settings, &mut planish_state);
+7 -1
View File
@@ -36,7 +36,13 @@ function wave(gain: (phase: number) => number): string {
const dry = wave(() => 1);
const knurled = wave(gainAt);
const count = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'][PLUGINS.length] ?? PLUGINS.length;
const WORDS = [
'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen',
'nineteen', 'twenty',
];
// Spelled out up to twenty, digits after that.
const count = WORDS[PLUGINS.length] ?? String(PLUGINS.length);
---
<section class="hero">
+46 -6
View File
@@ -71,7 +71,7 @@ const COPY: Record<string, Copy> = {
'Distortion chains build up harsh, ringing resonances. A static EQ cut takes out the tone along with the ring.',
points: [
'Finds ringing regions as they happen and pulls only those down.',
'Plain harmonic tone and transients pass; Selectivity sets what counts as harsh.',
'Each band works like a small compressor: Threshold sets what counts as harsh, Ratio how hard it is cut.',
'Four focus points on the plot to cut more or protect a region.',
'Delta monitoring to hear exactly what is removed.',
],
@@ -199,6 +199,51 @@ const COPY: Record<string, Copy> = {
latency: '≈ 67 ms',
tracksNotes: true,
},
quench: {
role: 'Sub-safe clipper',
metal: 'Quenching hardens hot metal in one fast step.',
problem:
'Hard-clipped masters clip the sub with the top: near the sub’s peaks only one side of the top clips, so the sub loses level and the low end fills with products that were not in the mix.',
points: [
'The sub below Split passes unclipped; the top is clipped in the room the sub leaves under the ceiling.',
'What the clip removes is high-passed, so its products stay out of the low band.',
'Compare against a plain clipper, or hear only what Quench takes away.',
'A meter shows how much the low end changed, for Quench and for a plain clip.',
],
loadOn: 'Master or bass bus, last',
latency: '< 1 ms',
tracksNotes: false,
},
ballast: {
role: 'Switch-up leveller',
metal: 'Ballast is the weight that keeps a ship level.',
problem:
'Patches at the same peak level still sound louder or quieter, so every switch-up lands at a different loudness and gets hand-set gain or clipping. A compressor evens them out but changes their punch.',
points: [
'One gain per part: nothing moves inside a note or switch-up.',
'Parts start at note starts, from the audio or MIDI, or on the host’s beat grid.',
'Loudness is K-weighted (BS.1770) over each part’s first 100 ms; Learn takes a reference part.',
'Measure from leaves the sub out of the measurement when Anvil makes it.',
],
loadOn: 'Bass, after the sound design',
latency: '≈ 172 ms',
tracksNotes: true,
},
forge: {
role: 'Tracked channel strip',
metal: 'A forge is the hearth where every piece is heated and worked.',
problem:
'Lathe, Temper and Knurl in a row each run their own tracker: the lookaheads add up to over 200 ms, and a tracker after Knurl hears its sub-octave and follows the wrong note.',
points: [
'One note tracker reads the input; Lathe, Temper and Knurl follow it as modules.',
'Any order, each module on or off, one latency of about 72 ms.',
'Every module sounds exactly like its standalone plugin.',
'The module pages are the standalone editors.',
],
loadOn: 'Bass, after the synth',
latency: '≈ 72 ms',
tracksNotes: true,
},
};
export interface Plugin extends Copy {
@@ -243,11 +288,6 @@ export const PLUGINS: Plugin[] = Object.entries(catalog).map(([slug, entry]) =>
/** Ideas from docs/ideas.md that are not built yet. */
export const IDEAS: { name: string; role: string; text: string; texture?: boolean }[] = [
{
name: 'Ballast',
role: 'Switch-up loudness leveller',
text: 'Measures perceived loudness per switch-up and sets gain only, so every patch lands at the same loudness without compression.',
},
{
name: 'Gauge',
role: 'Low-end mono check',