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

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