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

+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);
}