Upward and downward compression in four bands whose edges are harmonic numbers of the tracked note (default 1, 2-4, 5-16, 17 and up), so the same setting squashes the same harmonics on every note. A fixed-band OTT puts harmonic 2 of F1 and of C2 in different bands. - Three Linkwitz-Riley splits at sqrt(k(k+1)) x f0 with allpass compensation; at Depth 0 the bands sum within 0.05 dB. - Band level is the RMS over one period of the note, so heavy settings do not ripple at f0: a 40 Hz sine at Drive +12 dB, Time 10 % gets no harmonics above -110 dB. - At Depth 100 % on a saw, harmonics 1-12 change by the same amount on F1, C2 and G2 (within 0.2 dB). - Depth, Drive (on the detector only), Upward, Downward, Time, a gain per band; edges Low 1-8, Mid 2-16, High 3-48 with four splits. - Pitch from audio with about 67 ms lookahead, or from MIDI. - Editor: harmonic ruler with draggable edges, level and gain per band. - Catalog, workflows, editor-check, docs, signal chain and site copy.
310 lines
9.5 KiB
Rust
310 lines
9.5 KiB
Rust
use common_dsp::meter::HarmonicMeter;
|
||
use common_dsp::Saw;
|
||
use planish_core::{compressor_gain, Engine, Settings, Source, BANDS};
|
||
use std::f32::consts::{PI, TAU};
|
||
|
||
const RATE: f32 = 48000.;
|
||
|
||
fn engine(s: Settings) -> (Engine, usize) {
|
||
let mut e = Engine::new(RATE, 2).unwrap();
|
||
e.set_settings(s).unwrap();
|
||
let l = e.latency_samples() as usize;
|
||
(e, l)
|
||
}
|
||
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
|
||
let mut s = Saw::new(RATE, hz);
|
||
(0..(seconds * RATE) as usize)
|
||
.map(|_| s.sample() * amp)
|
||
.collect()
|
||
}
|
||
/// Sum of sines `(harmonic, amplitude)` on `hz`, with the phase in f64 so long signals stay
|
||
/// clean.
|
||
fn tones(hz: f32, seconds: f32, parts: &[(usize, f32)]) -> Vec<f32> {
|
||
(0..(seconds * RATE) as usize)
|
||
.map(|i| {
|
||
let t = i as f64 / RATE as f64;
|
||
parts
|
||
.iter()
|
||
.map(|&(k, a)| {
|
||
let phase = (TAU as f64 * hz as f64 * k as f64 * t) % (TAU as f64);
|
||
a * (phase as f32).sin()
|
||
})
|
||
.sum()
|
||
})
|
||
.collect()
|
||
}
|
||
fn sine(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
|
||
tones(hz, seconds, &[(1, amp)])
|
||
}
|
||
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
|
||
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
|
||
}
|
||
/// Harmonic levels of the last complete window of `x`.
|
||
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
|
||
let mut m = HarmonicMeter::new(RATE, 85.);
|
||
for &v in x {
|
||
m.push(v, hz);
|
||
}
|
||
*m.levels()
|
||
}
|
||
fn rms_db(x: &[f32]) -> f32 {
|
||
let p: f32 = x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32;
|
||
10. * (p + 1e-20).log10()
|
||
}
|
||
fn uncompressed() -> Settings {
|
||
Settings {
|
||
depth: 0.,
|
||
..Settings::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bands_sum_flat_without_compression() {
|
||
for hz in [35., 80., 250., 1000., 4000., 12000.] {
|
||
let x = sine(hz, 0.6, 0.5);
|
||
let (mut e, l) = engine(uncompressed());
|
||
let y = render(&mut e, &x);
|
||
let tail = y.len() / 2;
|
||
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
|
||
assert!(d.abs() < 0.05, "{hz} Hz: {d:+.3} dB");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn band_edges_sit_between_the_same_harmonics_on_every_note() {
|
||
// A saw with amplitude a has harmonic k at 2a/(πk). Band 1 holds harmonic 1 minus what
|
||
// its slope gives away; the split of the series between the bands is the same on every
|
||
// note.
|
||
let a = 0.5;
|
||
let fundamental = 10. * ((2. * a / PI).powi(2) / 2.).log10();
|
||
let mut first: Option<[f32; BANDS]> = None;
|
||
for hz in [43.65, 65.41, 130.81] {
|
||
let (mut e, _) = engine(uncompressed());
|
||
render(&mut e, &saw(hz, 1., a));
|
||
let f = e.take_analyzer();
|
||
assert!((f.hz - hz).abs() < 0.5, "{hz}: tracked {}", f.hz);
|
||
assert!((f.edges[0] - 2f32.sqrt() * hz).abs() < 1., "{:?}", f.edges);
|
||
assert!((f.edges[1] - 20f32.sqrt() * hz).abs() < 2., "{:?}", f.edges);
|
||
assert!(
|
||
(f.levels[0] - fundamental).abs() < 2.5,
|
||
"{hz}: {:?}",
|
||
f.levels
|
||
);
|
||
let levels = *first.get_or_insert(f.levels);
|
||
// The top band also holds the saw's own roll-off near Nyquist, which is not harmonic.
|
||
for (b, tolerance) in [0.4, 0.4, 0.4, 1.].into_iter().enumerate() {
|
||
let d = f.levels[b] - levels[b];
|
||
assert!(d.abs() < tolerance, "{hz}: {:?} vs {levels:?}", f.levels);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_same_setting_changes_the_same_harmonics_on_every_note() {
|
||
let s = Settings {
|
||
depth: 100.,
|
||
..Settings::default()
|
||
};
|
||
let notes = [43.65, 65.41, 98.];
|
||
let mut changes = Vec::new();
|
||
for hz in notes {
|
||
let x = saw(hz, 1.5, 0.5);
|
||
let (mut e, l) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
let a = harmonics(&x[..x.len() - l], hz);
|
||
let b = harmonics(&y[l..], hz);
|
||
changes.push((0..12).map(|k| b[k] - a[k]).collect::<Vec<f32>>());
|
||
}
|
||
// Compression must actually happen for the comparison to mean anything.
|
||
assert!(changes[0][0] < -6., "{:?}", changes[0]);
|
||
for (n, c) in changes.iter().enumerate().skip(1) {
|
||
for k in 0..12 {
|
||
let d = c[k] - changes[0][k];
|
||
assert!(
|
||
d.abs() < 1.5,
|
||
"note {n} harmonic {}: {c:?} vs {:?}",
|
||
k + 1,
|
||
changes[0]
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn upward_lifts_a_quiet_band_and_downward_pushes_a_loud_one() {
|
||
// Loud fundamental in band 1, a quiet 8th harmonic in band 3.
|
||
let hz = 55.;
|
||
let x = tones(hz, 1.5, &[(1, 0.7), (8, 0.003)]);
|
||
let a = harmonics(&x, hz);
|
||
let run = |upward: f32, downward: f32| {
|
||
let s = Settings {
|
||
depth: 100.,
|
||
upward,
|
||
downward,
|
||
..Settings::default()
|
||
};
|
||
let (mut e, l) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
let b = harmonics(&y[l..], hz);
|
||
[b[0] - a[0], b[7] - a[7]]
|
||
};
|
||
let [body, top] = run(100., 0.);
|
||
assert!(body.abs() < 0.5 && top > 5., "upward: {body:+.2} {top:+.2}");
|
||
let [body, top] = run(0., 100.);
|
||
assert!(
|
||
body < -10. && top.abs() < 1.,
|
||
"downward: {body:+.2} {top:+.2}"
|
||
);
|
||
}
|
||
|
||
/// Level in dB of harmonic `k` of `hz` in `x`, over whole periods (Goertzel).
|
||
fn partial(x: &[f32], hz: f32, k: usize) -> f32 {
|
||
let w = TAU as f64 * hz as f64 * k as f64 / RATE as f64;
|
||
let (mut re, mut im) = (0f64, 0f64);
|
||
for (i, &v) in x.iter().enumerate() {
|
||
re += v as f64 * (w * i as f64).cos();
|
||
im += v as f64 * (w * i as f64).sin();
|
||
}
|
||
let amp = 2. * (re * re + im * im).sqrt() / x.len() as f64;
|
||
20. * (amp + 1e-12).log10() as f32
|
||
}
|
||
|
||
#[test]
|
||
fn heavy_compression_does_not_ripple_at_the_fundamental() {
|
||
// A per-period level is steady on a steady note, so even fast, hard compression adds no
|
||
// harmonics to a sine. 40 Hz has a period of exactly 1200 samples; Snap is off so the
|
||
// window is the measured period, not the nearest semitone's.
|
||
let hz = 40.;
|
||
let x = sine(hz, 1.5, 0.5);
|
||
let distortion = |depth: f32| {
|
||
let s = Settings {
|
||
depth,
|
||
drive: 12.,
|
||
time: 10.,
|
||
snap: false,
|
||
..Settings::default()
|
||
};
|
||
let (mut e, _) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
let tail = &y[y.len() - 24000..];
|
||
let fundamental = partial(tail, hz, 1);
|
||
(2..6)
|
||
.map(|k| partial(tail, hz, k) - fundamental)
|
||
.fold(f32::MIN, f32::max)
|
||
};
|
||
let (dry, squashed) = (distortion(0.), distortion(100.));
|
||
assert!(squashed < -80., "{squashed:.1} dB (dry {dry:.1} dB)");
|
||
}
|
||
|
||
#[test]
|
||
fn silence_and_noise_floors_are_not_lifted() {
|
||
assert_eq!(compressor_gain(-120., 1., 1.), 0.);
|
||
assert!(compressor_gain(-60., 1., 1.) > 10.);
|
||
let s = Settings {
|
||
depth: 100.,
|
||
..Settings::default()
|
||
};
|
||
let x = saw(55., 1., 2e-5);
|
||
let (mut e, l) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
let tail = y.len() / 2;
|
||
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
|
||
assert!(d < 2., "{d:+.2} dB");
|
||
}
|
||
|
||
#[test]
|
||
fn bypass_passes_the_delayed_input_exactly() {
|
||
let s = Settings {
|
||
depth: 100.,
|
||
bypass: true,
|
||
..Settings::default()
|
||
};
|
||
let x = saw(55., 0.5, 0.5);
|
||
let (mut e, l) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
for i in l..x.len() {
|
||
assert_eq!(y[i], x[i - l]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn note_changes_do_not_click() {
|
||
let s = Settings {
|
||
depth: 100.,
|
||
..Settings::default()
|
||
};
|
||
let mut x = saw(43.65, 0.5, 0.5);
|
||
x.extend(saw(58.27, 0.5, 0.5));
|
||
x.extend(saw(87.31, 0.5, 0.5));
|
||
let (mut e, _) = engine(s);
|
||
let y = render(&mut e, &x);
|
||
let step = y
|
||
.windows(2)
|
||
.map(|w| (w[1] - w[0]).abs())
|
||
.fold(0f32, f32::max);
|
||
let in_step = x
|
||
.windows(2)
|
||
.map(|w| (w[1] - w[0]).abs())
|
||
.fold(0f32, f32::max);
|
||
assert!(y.iter().all(|v| v.is_finite()));
|
||
assert!(step < 2. * in_step, "{step} vs {in_step}");
|
||
}
|
||
|
||
#[test]
|
||
fn midi_source_moves_the_edges_at_the_delayed_note_time() {
|
||
let s = Settings {
|
||
source: Source::Midi,
|
||
..uncompressed()
|
||
};
|
||
let (mut e, l) = engine(s);
|
||
// 43.65 Hz is MIDI 29.
|
||
e.note_on(29);
|
||
render(&mut e, &saw(43.65, l as f32 / RATE - 0.002, 0.5));
|
||
assert_eq!(e.take_analyzer().hz, 0.);
|
||
render(&mut e, &saw(43.65, 0.01, 0.5));
|
||
let f = e.take_analyzer();
|
||
assert_eq!(f.note, Some(29));
|
||
assert!(
|
||
(f.edges[0] - 2f32.sqrt() * 43.65).abs() < 0.2,
|
||
"{:?}",
|
||
f.edges
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn edges_in_the_wrong_order_are_pushed_up() {
|
||
let s = Settings {
|
||
low: 4,
|
||
mid: 3,
|
||
high: 3,
|
||
..Settings::default()
|
||
};
|
||
assert!(s.validate().is_ok());
|
||
assert_eq!(s.tops(), [4, 5, 6]);
|
||
assert_eq!(s.band_label(0), "1–4");
|
||
assert_eq!(s.band_label(1), "5");
|
||
assert_eq!(s.band_label(3), "7+");
|
||
let d = Settings::default();
|
||
let labels: Vec<String> = (0..BANDS).map(|b| d.band_label(b)).collect();
|
||
assert_eq!(labels, ["1", "2–4", "5–16", "17+"]);
|
||
}
|
||
|
||
#[test]
|
||
fn settings_are_validated_and_serialise() {
|
||
let s = Settings::default();
|
||
let json = serde_json::to_string(&s).unwrap();
|
||
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
|
||
let mut bad = s;
|
||
bad.gains[2] = 40.;
|
||
assert!(bad.validate().is_err());
|
||
bad = s;
|
||
bad.low = 0;
|
||
assert!(bad.validate().is_err());
|
||
bad = s;
|
||
bad.time = f32::NAN;
|
||
assert!(bad.validate().is_err());
|
||
let mut e = Engine::new(RATE, 2).unwrap();
|
||
assert!(e.set_settings(bad).is_err());
|
||
assert_eq!(e.settings(), s);
|
||
}
|