Files
bass-foundry/plugins/braze/core/tests/dsp.rs
T
nvrl 330ec54150
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
feat(braze): add Braze tempo-locked beating
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.
2026-11-06 14:02:58 +01:00

279 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Sample indices address several signals at once.
#![allow(clippy::needless_range_loop)]
use braze_core::{cycles, envelope, Engine, Rate, Settings};
use common_dsp::Saw;
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
const TEMPO: f64 = 120.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e.set_transport(Some(TEMPO), Some(0.));
let l = e.latency_samples() as usize;
(e, l)
}
fn seconds(t: f32) -> usize {
(t * RATE) as usize
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<[f32; 2]> {
x.iter().map(|&s| e.process_sample([s, s])).collect()
}
/// A saw at `hz` from 0.05 s on, with a 1 ms fade-in.
fn saw(hz: f32, total: f32) -> Vec<f32> {
let mut osc = Saw::new(RATE, hz);
(0..seconds(total))
.map(|i| {
let start = seconds(0.05);
if i < start {
return 0.;
}
0.5 * osc.sample() * ((i - start) as f32 / seconds(0.001) as f32).min(1.)
})
.collect()
}
/// Amplitude of harmonic `n` of `hz` over one period centred on sample `at`.
fn harmonic(x: &[f32], at: usize, hz: f32, n: u32) -> f32 {
let half = (RATE / hz / 2.).round() as usize;
let w = TAU * hz * n as f32 / RATE;
let (mut re, mut im) = (0f32, 0f32);
for i in at - half..at + half {
re += x[i] * (w * i as f32).cos();
im -= x[i] * (w * i as f32).sin();
}
2. * (re * re + im * im).sqrt() / (2 * half) as f32
}
/// Beat position (quarter notes) of output sample `i`.
fn beats(i: usize, latency: usize) -> f64 {
(i as f64 - latency as f64) / RATE as f64 * TEMPO / 60.
}
/// Harmonics 1–4 of A1 at falling levels, a periodic note the tracker reads exactly.
fn partials(t: f64) -> f64 {
(1..=4)
.map(|n| 0.4 / n as f64 * (std::f64::consts::TAU * 55. * n as f64 * t).sin())
.sum()
}
#[test]
fn every_harmonic_beats_on_the_tempo_grid() {
for (harmonic, rate) in [(1, Rate::Eighth), (2, Rate::Sixteenth), (3, Rate::Quarter)] {
let s = Settings {
harmonic,
rate,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| partials(i as f64 / RATE as f64) as f32)
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
// Cycles of the chosen harmonic, from the grid alone: peaks on every multiple
// of `rate`. The copy lags the bass by (0.5 − frac(u)) periods.
let u = beats(i, l) / rate.quarters() / harmonic as f64 + 0.5;
let t = (i - l) as f64 / RATE as f64;
let lag = (0.5 - u.fract()) / 55.;
let ideal = (partials(t) + partials(t - lag)) / 2f64.sqrt();
worst = worst.max((y[i][0] - ideal as f32).abs());
}
assert!(worst < 3e-3, "harmonic {harmonic}, {rate:?}: {worst}");
}
}
#[test]
fn envelope_peaks_on_the_grid_and_harmonic_n_beats_n_over_k_as_fast() {
let s = Settings {
harmonic: 2,
..Settings::default()
};
// At cycle 0 every harmonic peaks: twice the bass, less the loudness correction.
for n in 1..=8 {
assert!((envelope(&s, n, 0., 1.) - 2f32.sqrt()).abs() < 1e-5);
}
// Harmonic 2 nulls half a cycle later; harmonic 1, beating half as fast, a cycle later.
assert!(envelope(&s, 2, 0.5, 1.) < 1e-5);
assert!(envelope(&s, 1, 1., 1.) < 1e-5);
assert!(envelope(&s, 4, 0.25, 1.) < 1e-5);
}
#[test]
fn the_copy_is_seamless_across_its_jumps() {
// A pure sine at exactly A1: the ideal copy is a smooth sine whose phase follows the beat.
let s = Settings {
harmonic: 1,
rate: Rate::Sixteenth,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| 0.5 * (TAU * 55. * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
let u = cycles(&s, beats(i, l), 0) + 0.5;
let dry = x[i - l];
let phase = TAU as f64 * (55. * (i - l) as f64 / RATE as f64 + u.fract());
let copy = -0.5 * phase.sin() as f32;
let ideal = (dry + copy) / 2f32.sqrt();
worst = worst.max((y[i][0] - ideal).abs());
}
assert!(worst < 2e-3, "{worst}");
}
#[test]
fn depth_zero_is_the_delayed_input() {
let (mut e, l) = engine(Settings {
depth: 0.,
low_cut: 0.,
..Settings::default()
});
let x = saw(55., 0.6);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i][0], x[i - l]);
}
}
#[test]
fn width_moves_the_right_channel_half_a_cycle() {
let s = Settings {
harmonic: 1,
width: 100.,
depth: 100.,
rate: Rate::Quarter,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1.5);
let y = render(&mut e, &x);
let left: Vec<f32> = y.iter().map(|v| v[0]).collect();
let right: Vec<f32> = y.iter().map(|v| v[1]).collect();
// On the beat the left fundamental peaks and the right one nulls; half a beat later
// they swap.
for (beat, loud, quiet) in [
(1., &left, &right),
(1.5, &right, &left),
(2., &left, &right),
] {
let at = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let (a, b) = (harmonic(loud, at, 55., 1), harmonic(quiet, at, 55., 1));
assert!(20. * (a / b).log10() > 20., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn note_changes_and_transport_jumps_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let (mut e, _) = engine(s);
// Phase-continuous sine from A1 to D2, no gap.
let mut phase = 0f32;
let x: Vec<f32> = (0..seconds(1.2))
.map(|i| {
let hz = if i < seconds(0.6) { 55. } else { 73.42 };
phase = (phase + hz / RATE).fract();
0.5 * (TAU * phase).sin()
})
.collect();
let mut y = Vec::new();
for (i, &v) in x.iter().enumerate() {
if i == seconds(0.9) {
// A locate to another bar.
e.set_transport(Some(TEMPO), Some(37.3));
}
y.push(e.process_sample([v, v])[0]);
}
// Two sines of peak 0.5 at up to 79 Hz move at most this much per sample (after the
// loudness correction), plus some room for the fades.
let bound = 2. * 0.5 * TAU * 79. / RATE / 2f32.sqrt() * 1.5;
for i in seconds(0.2)..y.len() - 1 {
assert!(
(y[i + 1] - y[i]).abs() < bound,
"{i}: {}",
(y[i + 1] - y[i]).abs()
);
}
}
#[test]
fn low_cut_keeps_the_sub_steady_and_the_beat_on_the_grid() {
let s = Settings {
harmonic: 1,
depth: 100.,
rate: Rate::Half,
low_cut: 150.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2.4);
let y: Vec<f32> = render(&mut e, &x).iter().map(|v| v[0]).collect();
// One cycle (a half note, 1 s at 120 BPM): the fundamental, under the cut, does not beat.
let sub: Vec<f32> = (0..50)
.map(|k| harmonic(&y, seconds(1.) + k * seconds(0.02), 55., 1))
.collect();
let (lo, hi) = sub
.iter()
.fold((f32::MAX, 0f32), |(a, b), &v| (a.min(v), b.max(v)));
// Without the cut it would null fully; what remains is leakage of the beating
// harmonics into the one-period window.
assert!(20. * (hi / lo).log10() < 1.5, "{lo} .. {hi}");
// And it keeps its level: the loudness correction for the copy applies above the cut.
let dry = harmonic(&x, seconds(1.) - l, 55., 1);
let mean = sub.iter().sum::<f32>() / sub.len() as f32;
assert!((20. * (mean / dry).log10()).abs() < 0.5, "{mean} vs {dry}");
// Harmonic 6 (330 Hz) beats 6 times per cycle: it peaks on the grid, the high-pass's
// phase is matched on the bass, and nulls 1/12 cycle later.
for beat in [2., 4.] {
let peak = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let null = peak + (RATE / 12.) as usize;
let (a, b) = (harmonic(&y, peak, 55., 6), harmonic(&y, null, 55., 6));
assert!(20. * (a / b).log10() > 15., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn the_ratio_follows_the_note() {
let (mut e, _) = engine(Settings {
harmonic: 1,
..Settings::default()
});
for hz in [55., 110.] {
let x = saw(hz, 0.5);
render(&mut e, &x);
let f = e.take_analyzer();
// An eighth at 120 BPM beats 4 times per second: r = 1 + 4 / f0.
assert!((f.ratio - (1. + 4. / hz)).abs() < 2e-3, "{hz}: {}", f.ratio);
}
}
#[test]
fn settings_are_validated_and_serialise() {
let mut e = Engine::new(RATE, 1).unwrap();
assert!(e
.set_settings(Settings {
harmonic: 0,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}