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:
1 parent
340376eee2
commit
7b989f7685
28 files changed
+4678
-23
No files matched your search
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user