feat(braze): add Braze tempo-locked beating
Check and Windows preview / check (anvil) (push) Successful in 2m15s
Check and Windows preview / check (braze) (push) Successful in 2m26s
Check and Windows preview / check (crucible) (push) Successful in 1m52s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m44s
Check and Windows preview / check (patina) (push) Successful in 2m38s
Check and Windows preview / check (planish) (push) Successful in 1m29s
Check and Windows preview / check (rivet) (push) Successful in 1m39s
Check and Windows preview / check (temper) (push) Successful in 1m37s
Check and Windows preview / check (weld) (push) Canceled after 2s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s

Adds a detuned copy of the bass whose beating stays on the tempo grid on
every note. The chosen harmonic k beats once per Rate note value, and
harmonic n beats n/k times as fast, so every harmonic is on the grid.

- The copy is read from a delay line whose delay falls one period of the
  tracked note per beat cycle (r = 1 + B/(k f0)) and jumps back exactly
  one period, crossfaded over 6 ms. On a periodic note the jump has no
  seam: the output matches the ideal sum within 0.003 on harmonics 1-4.
- The delay follows the host's beat position, shifted by the lookahead,
  so beat peaks land on the grid; free-running when stopped.
- Low cut: LR4 high-pass on the copy, the bass split by the same
  crossover, so the sub does not beat and keeps its level.
- Depth, Width, Phase, Gate, Output, Snap, Bypass. Pitch from audio
  (about 67 ms lookahead) or MIDI.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
- Catalog, workflows, editor-check, docs, signal chain and site copy.
This commit is contained in:
nvrl committed 2026-11-06 14:02:58 +01:00
1 parent e7f3346077
commit 330ec54150
28 files changed
+4565 -18

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use braze_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BrazeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: braze_ui::State,
gestures: [bool; FLOATS],
}
pub struct Braze {
params: Arc<BrazeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Braze {
fn default() -> Self {
Self {
params: Arc::new(BrazeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Braze {
const NAME: &'static str = "Braze";
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: braze_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| braze_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("braze-editor")
.min_size(egui::vec2(
braze_ui::WIDTH as f32 - 80.,
braze_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
braze_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The playhead follows the transport.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Braze {
const CLAP_ID: &'static str = "io.narl.bass-foundry.braze";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Adds a detuned copy whose beating stays on the tempo grid on every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Chorus,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Braze);
+219
View File
@@ -0,0 +1,219 @@
use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum RateParam {
#[id = "1-1"]
#[name = "1/1"]
Whole,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
#[id = "1-32"]
#[name = "1/32"]
ThirtySecond,
#[id = "1-4t"]
#[name = "1/4T"]
QuarterTriplet,
#[id = "1-8t"]
#[name = "1/8T"]
EighthTriplet,
#[id = "1-16t"]
#[name = "1/16T"]
SixteenthTriplet,
}
const RATES: [(RateParam, Rate); 9] = [
(RateParam::Whole, Rate::Whole),
(RateParam::Half, Rate::Half),
(RateParam::Quarter, Rate::Quarter),
(RateParam::Eighth, Rate::Eighth),
(RateParam::Sixteenth, Rate::Sixteenth),
(RateParam::ThirtySecond, Rate::ThirtySecond),
(RateParam::QuarterTriplet, Rate::QuarterTriplet),
(RateParam::EighthTriplet, Rate::EighthTriplet),
(RateParam::SixteenthTriplet, Rate::SixteenthTriplet),
];
fn to_param(rate: Rate) -> RateParam {
RATES
.iter()
.find(|(_, r)| *r == rate)
.map_or(RateParam::Eighth, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BrazeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "rate"]
pub rate: EnumParam<RateParam>,
#[id = "harmonic"]
pub harmonic: IntParam,
#[id = "depth"]
pub depth: FloatParam,
#[id = "low_cut"]
pub low_cut: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BrazeParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(braze_ui::WIDTH, braze_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
rate: EnumParam::new("Rate", to_param(d.rate)),
harmonic: IntParam::new(
"Harmonic",
d.harmonic as i32,
IntRange::Linear {
min: 1,
max: HARMONIC_MAX as i32,
},
),
depth: linear("Depth", d.depth, 0., 100., " %"),
low_cut: FloatParam::new(
"Low cut",
d.low_cut,
FloatRange::Skewed {
min: 0.,
max: LOW_CUT_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
width: linear("Width", d.width, 0., 100., " %"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BrazeParams {
pub fn settings(&self) -> Settings {
let rate = self.rate.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
rate: RATES
.iter()
.find(|(p, _)| *p == rate)
.map_or(Rate::Eighth, |(_, r)| *r),
harmonic: self.harmonic.value() as u32,
depth: self.depth.value(),
low_cut: self.low_cut.value(),
width: self.width.value(),
phase: self.phase.value(),
gate: self.gate.value(),
snap: self.snap.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.depth,
&self.low_cut,
&self.width,
&self.phase,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.depth, s.low_cut, s.width, s.phase, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.harmonic != old.harmonic {
setter.begin_set_parameter(&self.harmonic);
setter.set_parameter(&self.harmonic, new.harmonic as i32);
setter.end_set_parameter(&self.harmonic);
}
if new.rate != old.rate {
setter.begin_set_parameter(&self.rate);
setter.set_parameter(&self.rate, to_param(new.rate));
setter.end_set_parameter(&self.rate);
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}