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.
279 lines
9.2 KiB
Rust
279 lines
9.2 KiB
Rust
// 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());
|
||
}
|