Tester feedback asked for a threshold and a ratio. Each band now cuts like a small compressor: Threshold (dB above the local floor) replaces Selectivity, Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut out. Mix and Output move to the slider panel so the detection panel fits. common-ui gains rotary_text for knobs with custom value text.
572 lines
18 KiB
Rust
572 lines
18 KiB
Rust
use patina_core::*;
|
|
use std::f32::consts::TAU;
|
|
|
|
fn noise(i: usize) -> f32 {
|
|
let x = (i as u32).wrapping_mul(747796405).wrapping_add(2891336453);
|
|
let word = ((x >> ((x >> 28) + 4)) ^ x).wrapping_mul(277803737);
|
|
(((word >> 22) ^ word) as f32 / u32::MAX as f32 - 0.5) * 0.1
|
|
}
|
|
#[test]
|
|
fn delayed_identity_and_all_mix_positions_at_all_rates() {
|
|
for rate in [44100., 48000., 88200., 96000., 176400., 192000.] {
|
|
for mix in [0., 37., 100.] {
|
|
let mut e = Engine::new(rate, 2).unwrap();
|
|
e.set_settings(Settings {
|
|
depth: 0.,
|
|
mix,
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let n = e.latency_samples() as usize;
|
|
let mut error = 0f64;
|
|
let mut power = 0f64;
|
|
for i in 0..n * 4 {
|
|
let x = if i < n * 2 { noise(i) } else { 0. };
|
|
let y = e.process_sample([x, -x], Monitor::default());
|
|
let expected = if i >= n && i < n * 3 {
|
|
noise(i - n)
|
|
} else {
|
|
0.
|
|
};
|
|
error += (y[0] as f64 - expected as f64).powi(2);
|
|
power += (expected as f64).powi(2);
|
|
assert!((y[0] + y[1]).abs() < 1e-6);
|
|
}
|
|
assert!(
|
|
10. * (error / power).max(1e-30).log10() < -100.,
|
|
"rate {rate}, mix {mix}: {error}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn impulse_latency_delta_and_reset() {
|
|
let mut e = Engine::new(48000., 1).unwrap();
|
|
e.set_settings(Settings {
|
|
depth: 0.,
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let latency = e.latency_samples() as usize;
|
|
for i in 0..latency * 2 {
|
|
let y = e.process_sample([if i == 0 { 1. } else { 0. }, 0.], Monitor::default())[0];
|
|
assert!((y - if i == latency { 1. } else { 0. }).abs() < 1e-6);
|
|
}
|
|
e.reset();
|
|
for _ in 0..latency * 2 {
|
|
assert_eq!(e.process_sample([0., 0.], Monitor { delta: true }), [0.; 2]);
|
|
}
|
|
}
|
|
/// Band-limited sawtooth: every harmonic up to 18 kHz, amplitude 1/k.
|
|
fn saw(f0: f32, i: usize, rate: f32) -> f32 {
|
|
let t = i as f32 / rate;
|
|
(1..)
|
|
.map(|k| k as f32 * f0)
|
|
.take_while(|f| *f < 18000.)
|
|
.enumerate()
|
|
.map(|(k, f)| (TAU * f * t).sin() / (k + 1) as f32)
|
|
.sum::<f32>()
|
|
* 0.2
|
|
}
|
|
/// RBJ peaking biquad, used to put a known resonance on a test signal.
|
|
struct Peak {
|
|
b: [f32; 3],
|
|
a: [f32; 2],
|
|
z: [f32; 2],
|
|
}
|
|
impl Peak {
|
|
fn new(f: f32, q: f32, gain: f32, rate: f32) -> Self {
|
|
let a = 10f32.powf(gain / 40.);
|
|
let w = TAU * f / rate;
|
|
let alpha = w.sin() / (2. * q);
|
|
let a0 = 1. + alpha / a;
|
|
Self {
|
|
b: [
|
|
(1. + alpha * a) / a0,
|
|
-2. * w.cos() / a0,
|
|
(1. - alpha * a) / a0,
|
|
],
|
|
a: [-2. * w.cos() / a0, (1. - alpha / a) / a0],
|
|
z: [0.; 2],
|
|
}
|
|
}
|
|
fn tick(&mut self, x: f32) -> f32 {
|
|
let y = self.b[0] * x + self.z[0];
|
|
self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
|
|
self.z[1] = self.b[2] * x - self.a[1] * y;
|
|
y
|
|
}
|
|
}
|
|
fn cell(f: f32) -> usize {
|
|
(0..GRID)
|
|
.min_by(|a, b| {
|
|
(frequency(*a) - f)
|
|
.abs()
|
|
.total_cmp(&(frequency(*b) - f).abs())
|
|
})
|
|
.unwrap()
|
|
}
|
|
/// Run one second of a signal and return the last analyzer frame.
|
|
fn settle(settings: Settings, mut signal: impl FnMut(usize) -> f32) -> AnalyzerFrame {
|
|
let mut e = Engine::new(48000., 1).unwrap();
|
|
e.set_settings(settings).unwrap();
|
|
let mut frame = AnalyzerFrame::default();
|
|
for i in 0..48000 {
|
|
e.process_sample([signal(i), 0.], Monitor::default());
|
|
if let Some(f) = e.take_analyzer() {
|
|
frame = f;
|
|
}
|
|
}
|
|
frame
|
|
}
|
|
fn peak_reduction(frame: &AnalyzerFrame, low: f32, high: f32) -> f32 {
|
|
(0..GRID)
|
|
.filter(|i| (low..=high).contains(&frequency(*i)))
|
|
.map(|i| frame.reduction[i])
|
|
.fold(0., f32::max)
|
|
}
|
|
#[test]
|
|
fn plain_harmonic_tone_is_left_alone() {
|
|
for f0 in [110., 220., 440.] {
|
|
let frame = settle(Settings::default(), |i| saw(f0, i, 48000.));
|
|
let peak = peak_reduction(&frame, 20., 20000.);
|
|
assert!(peak < 1., "{f0} Hz sawtooth cut by {peak} dB");
|
|
}
|
|
}
|
|
#[test]
|
|
fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
|
|
for f0 in [110., 220.] {
|
|
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
|
let frame = settle(Settings::default(), |i| bump.tick(saw(f0, i, 48000.)));
|
|
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
|
|
assert!(near >= 4., "{f0} Hz: bump cut only {near} dB");
|
|
for f in [1000., 8000.] {
|
|
let away = frame.reduction[cell(f)];
|
|
assert!(away < 1., "{f0} Hz: {f} Hz cut by {away} dB");
|
|
}
|
|
assert!(peak_reduction(&frame, 20., 20000.) <= Settings::default().depth);
|
|
}
|
|
}
|
|
#[test]
|
|
fn depth_threshold_and_focus_each_do_one_thing() {
|
|
let run = |s: Settings| {
|
|
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
|
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
|
|
frame.reduction[cell(3000.)]
|
|
};
|
|
let base = run(Settings::default());
|
|
let shallow = run(Settings {
|
|
depth: 2.,
|
|
..Default::default()
|
|
});
|
|
let picky = run(Settings {
|
|
threshold: 5.5,
|
|
..Default::default()
|
|
});
|
|
let mut protect = Settings::default();
|
|
protect.focus[0] = Focus {
|
|
enabled: true,
|
|
frequency: 3000.,
|
|
width: 0.5,
|
|
sensitivity: -12.,
|
|
};
|
|
let protected = run(protect);
|
|
assert!(shallow <= 2.001 && shallow < base, "{shallow} vs {base}");
|
|
assert!(picky < base - 1., "{picky} vs {base}");
|
|
assert!(protected < base - 2., "{protected} vs {base}");
|
|
}
|
|
#[test]
|
|
fn detection_is_level_independent() {
|
|
let s = Settings {
|
|
transient_guard: 0.,
|
|
..Default::default()
|
|
};
|
|
let run = |gain: f32| {
|
|
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
|
settle(s, |i| bump.tick(saw(220., i, 48000.)) * gain)
|
|
};
|
|
let loud = run(1.);
|
|
let quiet = run(0.03);
|
|
for i in 0..GRID {
|
|
assert!(
|
|
(loud.reduction[i] - quiet.reduction[i]).abs() < 0.5,
|
|
"{} Hz: {} vs {}",
|
|
frequency(i),
|
|
loud.reduction[i],
|
|
quiet.reduction[i]
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn noise_does_not_cause_flickering_cuts() {
|
|
let mut e = Engine::new(48000., 1).unwrap();
|
|
e.set_settings(Settings::default()).unwrap();
|
|
let mut frames = Vec::new();
|
|
for i in 0..96000 {
|
|
e.process_sample([noise(i) * 3., 0.], Monitor::default());
|
|
if let Some(f) = e.take_analyzer() {
|
|
if i > 24000 {
|
|
frames.push(f);
|
|
}
|
|
}
|
|
}
|
|
let mean = frames
|
|
.iter()
|
|
.map(|f| f.reduction.iter().sum::<f32>() / GRID as f32)
|
|
.sum::<f32>()
|
|
/ frames.len() as f32;
|
|
let jitter = frames
|
|
.windows(2)
|
|
.map(|w| {
|
|
(0..GRID)
|
|
.map(|i| (w[1].reduction[i] - w[0].reduction[i]).abs())
|
|
.sum::<f32>()
|
|
/ GRID as f32
|
|
})
|
|
.sum::<f32>()
|
|
/ (frames.len() - 1) as f32;
|
|
assert!(mean < 0.5, "white noise mean cut {mean} dB");
|
|
assert!(jitter < 0.1, "frame-to-frame change {jitter} dB");
|
|
}
|
|
#[test]
|
|
fn processing_range_is_respected_and_cuts_recover_in_silence() {
|
|
let s = Settings {
|
|
low: 500.,
|
|
high: 9000.,
|
|
transient_guard: 0.,
|
|
release: 20.,
|
|
..Default::default()
|
|
};
|
|
let mut e = Engine::new(48000., 2).unwrap();
|
|
e.set_settings(s).unwrap();
|
|
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
|
let mut frame = AnalyzerFrame::default();
|
|
for i in 0..48000 {
|
|
let x = bump.tick(saw(220., i, 48000.));
|
|
e.process_sample([x, -x], Monitor::default());
|
|
if let Some(f) = e.take_analyzer() {
|
|
frame = f;
|
|
}
|
|
}
|
|
assert!(frame.reduction[cell(3000.)] > 3.);
|
|
for i in 0..GRID {
|
|
if frequency(i) < 500. || frequency(i) > 9000. {
|
|
assert_eq!(frame.reduction[i], 0.);
|
|
}
|
|
}
|
|
for _ in 0..48000 {
|
|
e.process_sample([0.; 2], Monitor::default());
|
|
}
|
|
let f = e.take_analyzer().unwrap();
|
|
assert!(f.reduction.iter().all(|d| *d < 0.001));
|
|
}
|
|
#[test]
|
|
fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
|
|
let mut e = Engine::new(48000., 1).unwrap();
|
|
let before = e.settings();
|
|
assert!(e
|
|
.set_settings(Settings {
|
|
attack: f32::NAN,
|
|
..before
|
|
})
|
|
.is_err());
|
|
assert_eq!(e.settings(), before);
|
|
assert!(Engine::new(8000., 2).is_err());
|
|
for _ in 0..10000 {
|
|
let y = e.process_sample([f32::NAN, f32::INFINITY], Monitor::default());
|
|
assert!(y.iter().all(|v| v.is_finite()));
|
|
}
|
|
}
|
|
#[test]
|
|
fn soft_knee_and_presets_are_valid() {
|
|
for i in 0..Settings::PRESETS.len() {
|
|
Settings::preset(i).validate().unwrap();
|
|
}
|
|
assert_eq!(knee(-2., 3.), 0.);
|
|
assert_eq!(knee(5., 3.), 5.);
|
|
assert_eq!(knee(-1., 0.), 0.);
|
|
let p = Focus {
|
|
enabled: true,
|
|
frequency: 2000.,
|
|
width: 1.,
|
|
sensitivity: -12.,
|
|
};
|
|
assert_eq!(focus_offset(2000., &[p; 4]), -48.);
|
|
}
|
|
|
|
#[test]
|
|
fn settled_delta_and_bypass_have_precise_taps() {
|
|
let s = Settings {
|
|
attack: 1.,
|
|
transient_guard: 0.,
|
|
..Default::default()
|
|
};
|
|
let mut wet = Engine::new(48000., 1).unwrap();
|
|
wet.set_settings(s).unwrap();
|
|
let mut delta = Engine::new(48000., 1).unwrap();
|
|
delta.set_settings(s).unwrap();
|
|
let mut bypass = Engine::new(48000., 1).unwrap();
|
|
bypass.set_settings(Settings { bypass: true, ..s }).unwrap();
|
|
let latency = wet.latency_samples() as usize;
|
|
for i in 0..24000 {
|
|
let x = noise(i);
|
|
let a = wet.process_sample([x, 0.], Monitor::default())[0];
|
|
let b = delta.process_sample([x, 0.], Monitor { delta: true })[0];
|
|
let c = bypass.process_sample([x, 0.], Monitor::default())[0];
|
|
if i > 12000 {
|
|
let expected = noise(i - latency);
|
|
assert!((a + b - expected).abs() < 1e-5);
|
|
assert!((c - expected).abs() < 1e-6);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn narrow_ring_on_noise_is_cut_and_sharpness_narrows_the_cut() {
|
|
let width = |sharpness: f32| {
|
|
let s = Settings {
|
|
sharpness,
|
|
..Default::default()
|
|
};
|
|
let frame = settle(s, |i| {
|
|
0.05 * (TAU * 5000. * i as f32 / 48000.).sin() + noise(i) * 3.
|
|
});
|
|
let peak = frame.reduction[cell(5000.)];
|
|
let wide = (0..GRID)
|
|
.filter(|i| frame.reduction[*i] > peak * 0.5)
|
|
.count();
|
|
(peak, wide)
|
|
};
|
|
let (soft, soft_width) = width(0.);
|
|
let (sharp, sharp_width) = width(100.);
|
|
assert!(soft > 3. && sharp > 3., "{soft} / {sharp}");
|
|
assert!(sharp_width < soft_width, "{sharp_width} vs {soft_width}");
|
|
}
|
|
#[test]
|
|
fn high_sample_rates_detect_the_same_bump() {
|
|
for rate in [96000., 192000.] {
|
|
let mut e = Engine::new(rate, 1).unwrap();
|
|
e.set_settings(Settings::default()).unwrap();
|
|
let mut bump = Peak::new(3000., 4., 12., rate);
|
|
let mut frame = AnalyzerFrame::default();
|
|
for i in 0..rate as usize {
|
|
e.process_sample([bump.tick(saw(220., i, rate)), 0.], Monitor::default());
|
|
if let Some(f) = e.take_analyzer() {
|
|
frame = f;
|
|
}
|
|
}
|
|
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
|
|
let away = peak_reduction(&frame, 20., 1500.);
|
|
assert!(near >= 4. && away < 1., "{rate}: {near} / {away}");
|
|
}
|
|
}
|
|
|
|
/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2).
|
|
const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31];
|
|
/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`.
|
|
fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec<f32> {
|
|
let rate = 48000.;
|
|
let n = (rate * seconds) as usize;
|
|
let mut x = vec![0f32; n];
|
|
for k in 1..=(16000. / f0) as usize {
|
|
let f = k as f32 * f0;
|
|
let a = 0.08 * shape(k, f) / k as f32;
|
|
let w = TAU * f / rate;
|
|
for (i, v) in x.iter_mut().enumerate() {
|
|
*v += a * (w * i as f32 + k as f32).sin();
|
|
}
|
|
}
|
|
x
|
|
}
|
|
/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz`
|
|
/// (largest within ±3 grid cells) over the second half of the last note.
|
|
fn tone_cut(
|
|
guard: f32,
|
|
notes: &[f32],
|
|
rounds: usize,
|
|
shape: &dyn Fn(usize, f32) -> f32,
|
|
hz: &dyn Fn(f32) -> f32,
|
|
) -> f32 {
|
|
let mut e = Engine::new(48000., 1).unwrap();
|
|
e.set_settings(Settings {
|
|
tone_guard: guard,
|
|
low: 150.,
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let last = *notes.last().unwrap();
|
|
let at = patina_core::cell(hz(last)).round() as usize;
|
|
let (mut sum, mut count) = (0., 0);
|
|
for round in 0..rounds {
|
|
for &f0 in notes {
|
|
let x = bass(f0, 0.4, shape);
|
|
let measure = round + 1 == rounds && f0 == last;
|
|
for (i, v) in x.iter().enumerate() {
|
|
e.process_sample([*v, 0.], Monitor::default());
|
|
if let Some(frame) = e.take_analyzer() {
|
|
if measure && i > x.len() / 2 {
|
|
sum += frame.reduction[at - 3..=at + 3]
|
|
.iter()
|
|
.fold(0f32, |m, v| m.max(*v));
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sum / count as f32
|
|
}
|
|
fn harmonic_six(k: usize, _: f32) -> f32 {
|
|
if k == 6 {
|
|
4.
|
|
} else {
|
|
1.
|
|
}
|
|
}
|
|
fn formant(_: usize, f: f32) -> f32 {
|
|
let z = (f / 1500.).log2() / 0.15;
|
|
1. + 3. * (-z * z).exp()
|
|
}
|
|
#[test]
|
|
fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() {
|
|
let h6 = |f0: f32| 6. * f0;
|
|
let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6);
|
|
let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6);
|
|
assert!(
|
|
plain > 4.,
|
|
"without guard the boosted harmonic is cut: {plain}"
|
|
);
|
|
assert!(
|
|
guarded < 0.25 * plain,
|
|
"guard keeps the harmonic: {guarded} vs {plain}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn tone_guard_still_cuts_resonances_fixed_in_hz() {
|
|
let at = |_: f32| 1500.;
|
|
let plain = tone_cut(0., &LINE, 3, &formant, &at);
|
|
let guarded = tone_cut(100., &LINE, 3, &formant, &at);
|
|
assert!(plain > 4., "the formant is cut: {plain}");
|
|
assert!(
|
|
guarded > 0.85 * plain,
|
|
"guard still cuts it: {guarded} vs {plain}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn tone_guard_needs_different_notes_to_tell_bumps_apart() {
|
|
let h6 = |f0: f32| 6. * f0;
|
|
let one = [LINE[2]];
|
|
let plain = tone_cut(0., &one, 10, &harmonic_six, &h6);
|
|
let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6);
|
|
assert!(
|
|
(guarded - plain).abs() < 0.5,
|
|
"one held note gives no evidence: {guarded} vs {plain}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn old_settings_without_tone_guard_still_load() {
|
|
let mut json = serde_json::to_value(Settings::default()).unwrap();
|
|
json.as_object_mut().unwrap().remove("tone_guard");
|
|
let s: Settings = serde_json::from_value(json).unwrap();
|
|
assert_eq!(s.tone_guard, 0.);
|
|
}
|
|
|
|
/// Cut at the bump for a resonance of `gain` dB (Q 4 at 3 kHz) on a 220 Hz saw.
|
|
fn bump_cut(s: Settings, gain: f32) -> f32 {
|
|
let mut bump = Peak::new(3000., 4., gain, 48000.);
|
|
let frame = settle(
|
|
Settings {
|
|
transient_guard: 0.,
|
|
..s
|
|
},
|
|
|i| bump.tick(saw(220., i, 48000.)),
|
|
);
|
|
peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26)
|
|
}
|
|
#[test]
|
|
fn ratio_spares_small_peaks_and_still_cuts_big_ones() {
|
|
let at = |ratio: f32, gain: f32| {
|
|
bump_cut(
|
|
Settings {
|
|
depth: 20.,
|
|
ratio,
|
|
..Default::default()
|
|
},
|
|
gain,
|
|
)
|
|
};
|
|
let (small_hard, big_hard) = (at(RATIO_INFINITE, 6.), at(RATIO_INFINITE, 18.));
|
|
let (small, big) = (at(2., 6.), at(2., 18.));
|
|
// At 2:1 each cut is about half the ∞:1 cut.
|
|
for (half, full) in [(small, small_hard), (big, big_hard)] {
|
|
assert!((half / full - 0.5).abs() < 0.15, "{half} vs {full}");
|
|
}
|
|
assert!(small < 1., "small bump cut by {small} dB at 2:1");
|
|
assert!(big > 4., "big bump cut only {big} dB at 2:1");
|
|
// Ratio between the two ends gives a cut between them.
|
|
let mid = at(4., 18.);
|
|
assert!(big < mid && mid < big_hard, "{big} < {mid} < {big_hard}");
|
|
}
|
|
#[test]
|
|
fn cut_grows_with_the_bump_until_depth() {
|
|
let s = Settings {
|
|
depth: 20.,
|
|
ratio: 3.,
|
|
..Default::default()
|
|
};
|
|
let cuts: Vec<f32> = [4., 8., 12., 18., 24.]
|
|
.iter()
|
|
.map(|g| bump_cut(s, *g))
|
|
.collect();
|
|
for w in cuts.windows(2) {
|
|
assert!(w[1] > w[0] + 0.5, "{cuts:?}");
|
|
}
|
|
let capped = bump_cut(Settings { depth: 4., ..s }, 24.);
|
|
assert!(capped <= 4.001 && capped > 3., "{capped}");
|
|
}
|
|
#[test]
|
|
fn threshold_moves_where_cuts_start() {
|
|
let low = bump_cut(Settings::default(), 9.);
|
|
let high = bump_cut(
|
|
Settings {
|
|
threshold: 9.,
|
|
..Default::default()
|
|
},
|
|
9.,
|
|
);
|
|
assert!(low > 2. && high < 0.5, "{low} / {high}");
|
|
}
|
|
#[test]
|
|
fn hard_knee_starts_cutting_closer_to_the_threshold() {
|
|
// A bump just under the threshold: the soft knee already cuts a little, the hard knee not.
|
|
let s = Settings {
|
|
threshold: 4.,
|
|
..Default::default()
|
|
};
|
|
let soft = bump_cut(s, 6.);
|
|
let hard = bump_cut(
|
|
Settings {
|
|
knee: Knee::Hard,
|
|
..s
|
|
},
|
|
6.,
|
|
);
|
|
assert!(soft > hard + 0.2, "soft {soft} vs hard {hard}");
|
|
assert_eq!(knee(0.25, 1.), 0.28125);
|
|
}
|
|
#[test]
|
|
fn settings_from_before_threshold_still_load() {
|
|
let mut json = serde_json::to_value(Settings::default()).unwrap();
|
|
let map = json.as_object_mut().unwrap();
|
|
for key in ["threshold", "ratio", "knee", "tone_guard"] {
|
|
map.remove(key);
|
|
}
|
|
map.insert("selectivity".into(), 100.into());
|
|
let s: Settings = serde_json::from_value(json).unwrap();
|
|
assert_eq!(s.threshold, 5.5);
|
|
assert_eq!(s.ratio, RATIO_INFINITE);
|
|
assert_eq!(s.knee, Knee::Soft);
|
|
assert!(serde_json::from_str::<Settings>(r#"{"oops":1}"#).is_err());
|
|
let back: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
|
assert_eq!(back, s);
|
|
}
|