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.
This commit is contained in:
nvrl committed 2026-11-06 16:30:23 +01:00
1 parent 340376eee2
commit 7b989f7685
28 files changed
+4678 -23

No files matched your search

+3 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench, ballast]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench, ballast, forge]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -66,6 +66,7 @@ jobs:
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
@@ -101,6 +102,7 @@ jobs:
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*", "quench-v*", "ballast-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
+28
View File
@@ -202,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"
+3 -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/quench/core", "plugins/quench/ui", "plugins/ballast/core", "plugins/ballast/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]
@@ -35,3 +35,5 @@ opt-level = 2
opt-level = 2
[profile.dev.package.ballast-core]
opt-level = 2
[profile.dev.package.forge-core]
opt-level = 2
+9 -3
View File
@@ -32,9 +32,10 @@ note. Each plugin is a separate product with its own version, guide and release.
| **[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, Braze and Ballast detect notes 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
@@ -44,7 +45,8 @@ 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; **quenching** hardens hot metal in one
fast step; **ballast** is the weight that keeps a ship level.
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*.
@@ -75,6 +77,9 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
<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
@@ -133,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`, `braze`, `quench` and `ballast`. 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.
@@ -175,6 +180,7 @@ plugins/
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
+10 -3
View File
@@ -1,19 +1,24 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl, Planish, Braze and Ballast 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 → 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.
@@ -58,13 +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, Ballast
about 172 ms, Quench under 1 ms. A full chain is
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
+6 -3
View File
@@ -25,8 +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, Braze and Ballast, 4× oversampling (also Quench's),
BS.1770 K-weighting (Ballast), 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
@@ -46,7 +48,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`, `planish`, `braze`, `quench`, `ballast`.
`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
@@ -149,6 +151,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| 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
## Unreleased (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** (unreleased).
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

+26 -1
View File
@@ -270,7 +270,7 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
(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, unreleased 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 +291,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 (unreleased 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
+23
View File
@@ -199,5 +199,28 @@
"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"
]
}
}
+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();
}
}
+29
View File
@@ -197,6 +197,8 @@ dependencies = [
"crucible-core",
"crucible-ui",
"egui",
"forge-core",
"forge-ui",
"image",
"knurl-core",
"knurl-ui",
@@ -279,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"
+2
View File
@@ -33,5 +33,7 @@ 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" }
+45 -5
View File
@@ -12,13 +12,13 @@ fn main() {
.unwrap_or("rivet");
if ![
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish",
"braze", "quench", "ballast",
"braze", "quench", "ballast", "forge",
]
.contains(&plugin)
{
eprintln!(
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, \
braze, quench or ballast"
braze, quench, ballast or forge"
);
std::process::exit(2);
}
@@ -34,7 +34,10 @@ fn main() {
let braze = plugin == "braze";
let quench = plugin == "quench";
let ballast = plugin == "ballast";
let (width, height) = if 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)
@@ -195,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.],
@@ -416,7 +452,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if ballast {
if forge {
forge_ui::style(&ctx);
} else if ballast {
ballast_ui::style(&ctx);
} else if quench {
quench_ui::style(&ctx);
@@ -461,7 +499,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if ballast {
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);
+15 -5
View File
@@ -229,6 +229,21 @@ const COPY: Record<string, Copy> = {
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 {
@@ -273,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',