use crate::auto_makeup::MeasuredMakeup;
use crate::detector::{DetectionMode, LevelDetector};
use crate::envelope::DualRelease;
use crate::lookahead::LookaheadBuffer;
use math_audio_iir_fir::{Biquad, BiquadFilterType, peq_butterworth_highpass};
const RMS_WINDOW_MS: f32 = 10.0;
const MEASURED_MAKEUP_SMOOTHING_MS: f32 = 1000.0;
const MAX_LOOKAHEAD_MS: f32 = 20.0;
const DUAL_RELEASE_SLOW_MULTIPLIER: f32 = 4.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DynamicsMode {
Compress,
Expand,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GateState {
Open,
Hold,
Closing,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SidechainFilterMode {
Off,
Hpf { freq_hz: f32, order_index: usize },
Tilt { tilt_db: f32 },
}
pub struct DynamicsCore {
mode: DynamicsMode,
channels: usize,
sample_rate: u32,
envelope: Vec<f32>,
attack_coeff: f32,
release_coeff: f32,
attack_ms: f32,
release_ms: f32,
level_detectors: Vec<LevelDetector>,
detection_mode_index: usize,
sidechain_hpf_biquads: Vec<Vec<Biquad>>,
sidechain_hpf_hz: f32,
sidechain_hpf_order_index: usize, sidechain_tilt_biquads: Vec<Biquad>,
sidechain_tilt_db: f32,
sidechain_filter_mode: SidechainFilterMode,
dual_release: Vec<DualRelease>,
program_dependent_release: bool,
measured_makeup: MeasuredMakeup,
lookahead_buffer: LookaheadBuffer,
lookahead_ms: f32,
lookahead_frame_buf: Vec<f32>,
gate_state: Vec<GateState>,
hold_counter: Vec<usize>,
hysteresis_db: f32,
hold_ms: f32,
range_db: f32,
}
impl DynamicsCore {
pub fn new(mode: DynamicsMode, channels: usize, sample_rate: u32) -> Self {
let detection_mode = DetectionMode::Peak;
let max_lookahead_samples =
(MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
let attack_ms = 10.0;
let release_ms = 100.0;
let mut core = Self {
mode,
channels,
sample_rate,
envelope: vec![0.0; channels],
attack_coeff: 0.0,
release_coeff: 0.0,
attack_ms,
release_ms,
level_detectors: (0..channels)
.map(|_| LevelDetector::new(detection_mode, sample_rate))
.collect(),
detection_mode_index: 0,
sidechain_hpf_biquads: Vec::new(),
sidechain_hpf_hz: 0.0,
sidechain_hpf_order_index: 0,
sidechain_tilt_biquads: Vec::new(),
sidechain_tilt_db: 0.0,
sidechain_filter_mode: SidechainFilterMode::Off,
dual_release: (0..channels)
.map(|_| {
DualRelease::new(
release_ms,
release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
sample_rate,
)
})
.collect(),
program_dependent_release: false,
measured_makeup: MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate),
lookahead_buffer: LookaheadBuffer::new(max_lookahead_samples.max(1), channels),
lookahead_ms: 0.0,
lookahead_frame_buf: vec![0.0; channels],
gate_state: vec![GateState::Open; channels],
hold_counter: vec![0; channels],
hysteresis_db: 3.0,
hold_ms: 50.0,
range_db: 40.0,
};
core.attack_coeff = time_to_coeff(attack_ms, sample_rate);
core.release_coeff = time_to_coeff(release_ms, sample_rate);
core.lookahead_buffer.set_delay(1);
core
}
pub fn initialize(&mut self, sample_rate: u32) {
self.sample_rate = sample_rate;
self.attack_coeff = time_to_coeff(self.attack_ms, sample_rate);
self.release_coeff = time_to_coeff(self.release_ms, sample_rate);
self.rebuild_sidechain_hpf_internal();
self.rebuild_sidechain_tilt_internal();
let mode = self.detection_mode();
self.level_detectors = (0..self.channels)
.map(|_| LevelDetector::new(mode, sample_rate))
.collect();
let max_lookahead_samples =
(MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
self.lookahead_buffer
.resize(max_lookahead_samples.max(1), self.channels);
if self.lookahead_ms > 0.0 {
self.lookahead_buffer
.set_delay_ms(self.lookahead_ms, sample_rate);
} else {
self.lookahead_buffer.set_delay(1);
}
self.dual_release = (0..self.channels)
.map(|_| {
DualRelease::new(
self.release_ms,
self.release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
sample_rate,
)
})
.collect();
self.measured_makeup = MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate);
self.lookahead_frame_buf.resize(self.channels, 0.0);
}
pub fn reset(&mut self) {
self.envelope.fill(0.0);
self.gate_state.fill(GateState::Open);
self.hold_counter.fill(0);
self.rebuild_sidechain_hpf_internal();
self.rebuild_sidechain_tilt_internal();
for det in &mut self.level_detectors {
det.reset();
}
self.lookahead_buffer.reset();
for dr in &mut self.dual_release {
dr.reset();
}
self.measured_makeup.reset();
}
pub fn set_attack_release(&mut self, attack_ms: f32, release_ms: f32) {
self.attack_ms = attack_ms;
self.release_ms = release_ms;
self.attack_coeff = time_to_coeff(attack_ms, self.sample_rate);
self.release_coeff = time_to_coeff(release_ms, self.sample_rate);
for dr in &mut self.dual_release {
dr.set_times(
release_ms,
release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
self.sample_rate,
);
}
}
pub fn set_sidechain_hpf(&mut self, freq_hz: f32, order_index: usize) {
self.sidechain_hpf_hz = freq_hz;
self.sidechain_hpf_order_index = order_index;
self.sidechain_filter_mode = if freq_hz > 0.0 {
SidechainFilterMode::Hpf {
freq_hz,
order_index,
}
} else {
SidechainFilterMode::Off
};
self.rebuild_sidechain_hpf_internal();
self.sidechain_tilt_biquads.clear();
self.sidechain_tilt_db = 0.0;
}
pub fn set_sidechain_tilt(&mut self, tilt_db: f32) {
self.sidechain_tilt_db = tilt_db;
if tilt_db.abs() < 0.01 {
self.sidechain_filter_mode = SidechainFilterMode::Off;
self.sidechain_tilt_biquads.clear();
return;
}
self.sidechain_filter_mode = SidechainFilterMode::Tilt { tilt_db };
self.sidechain_hpf_biquads.clear();
self.sidechain_hpf_hz = 0.0;
self.rebuild_sidechain_tilt_internal();
}
pub fn set_sidechain_filter(&mut self, mode: SidechainFilterMode) {
match mode {
SidechainFilterMode::Off => {
self.sidechain_hpf_biquads.clear();
self.sidechain_hpf_hz = 0.0;
self.sidechain_tilt_biquads.clear();
self.sidechain_tilt_db = 0.0;
self.sidechain_filter_mode = SidechainFilterMode::Off;
}
SidechainFilterMode::Hpf {
freq_hz,
order_index,
} => {
self.set_sidechain_hpf(freq_hz, order_index);
}
SidechainFilterMode::Tilt { tilt_db } => {
self.set_sidechain_tilt(tilt_db);
}
}
}
pub fn set_detection_mode(&mut self, mode_index: usize) {
self.detection_mode_index = mode_index;
let mode = self.detection_mode();
for det in &mut self.level_detectors {
det.set_mode(mode);
}
}
pub fn set_lookahead_ms(&mut self, ms: f32) {
self.lookahead_ms = ms.clamp(0.0, MAX_LOOKAHEAD_MS);
if self.lookahead_ms > 0.0 {
self.lookahead_buffer
.set_delay_ms(self.lookahead_ms, self.sample_rate);
} else {
self.lookahead_buffer.set_delay(1);
}
}
pub fn set_program_dependent_release(&mut self, enabled: bool) {
self.program_dependent_release = enabled;
}
pub fn set_expand_params(&mut self, hysteresis_db: f32, hold_ms: f32, range_db: f32) {
self.hysteresis_db = hysteresis_db;
self.hold_ms = hold_ms;
self.range_db = range_db;
}
pub fn mode(&self) -> DynamicsMode {
self.mode
}
pub fn channels(&self) -> usize {
self.channels
}
#[inline]
pub fn apply_sidechain_filter(&mut self, ch: usize, sample: f32) -> f32 {
match self.sidechain_filter_mode {
SidechainFilterMode::Off => sample,
SidechainFilterMode::Hpf { .. } => {
if ch >= self.sidechain_hpf_biquads.len() {
return sample;
}
let biquads: &mut [Biquad] = &mut self.sidechain_hpf_biquads[ch];
let mut x = sample as f64;
for bq in biquads.iter_mut() {
x = bq.process(x);
}
x as f32
}
SidechainFilterMode::Tilt { .. } => {
if ch >= self.sidechain_tilt_biquads.len() {
return sample;
}
self.sidechain_tilt_biquads[ch].process(sample as f64) as f32
}
}
}
#[inline]
pub fn detect_level(&mut self, ch: usize, sample: f32) -> f32 {
if self.detection_mode_index == 0 {
sample.abs()
} else {
self.level_detectors[ch].process_linear(sample)
}
}
#[inline]
pub fn calculate_gain_reduction(
&self,
input_db: f32,
threshold: f32,
ratio: f32,
knee_db: f32,
) -> f32 {
match self.mode {
DynamicsMode::Compress => calculate_compress_gr(input_db, threshold, ratio, knee_db),
DynamicsMode::Expand => {
calculate_expand_atten(input_db, threshold, ratio, knee_db, self.range_db)
}
}
}
#[inline]
pub fn apply_envelope(&mut self, ch: usize, target_gr: f32) -> f32 {
let coeff = if target_gr > self.envelope[ch] {
self.attack_coeff
} else {
match self.mode {
DynamicsMode::Compress if self.program_dependent_release => {
self.dual_release[ch].process(target_gr)
}
_ => self.release_coeff,
}
};
self.envelope[ch] = target_gr + coeff * (self.envelope[ch] - target_gr);
self.envelope[ch]
}
#[inline]
pub fn process_gate_state(
&mut self,
ch: usize,
input_db: f32,
threshold: f32,
ratio: f32,
knee_db: f32,
) -> f32 {
let hold_samples = (self.hold_ms * 0.001 * self.sample_rate as f32) as usize;
let open_th = threshold;
let close_th = threshold - self.hysteresis_db;
match self.gate_state[ch] {
GateState::Open => {
if input_db < open_th {
self.gate_state[ch] = GateState::Hold;
self.hold_counter[ch] = hold_samples;
}
0.0
}
GateState::Hold => {
if input_db >= open_th {
self.gate_state[ch] = GateState::Open;
self.hold_counter[ch] = 0;
0.0
} else if self.hold_counter[ch] > 0 {
self.hold_counter[ch] -= 1;
0.0
} else if input_db < close_th {
self.gate_state[ch] = GateState::Closing;
self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
} else {
0.0
}
}
GateState::Closing => {
if input_db >= open_th {
self.gate_state[ch] = GateState::Open;
0.0
} else {
self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
}
}
}
}
#[inline]
pub fn envelope_db(&self, ch: usize) -> f32 {
self.envelope[ch]
}
#[inline]
pub fn measured_makeup_db(&self) -> f32 {
self.measured_makeup.makeup_db()
}
#[inline]
pub fn measured_makeup_linear(&self) -> f32 {
self.measured_makeup.makeup_linear()
}
#[inline]
pub fn update_measured_makeup(&mut self, gain_reduction: f32) {
self.measured_makeup.update(gain_reduction);
}
#[inline]
pub fn lookahead_process_frame(&mut self, input: &[f32], output: &mut [f32]) {
self.lookahead_buffer.process_frame(input, output);
}
pub fn lookahead_delay_samples(&self) -> usize {
if self.lookahead_ms <= 0.0 {
return 0;
}
(self.lookahead_ms * 0.001 * self.sample_rate as f32).round() as usize
}
#[inline]
pub fn lookahead_frame_buf(&mut self) -> &mut [f32] {
&mut self.lookahead_frame_buf
}
pub fn gate_state(&self, ch: usize) -> GateState {
self.gate_state[ch]
}
pub fn range_db(&self) -> f32 {
self.range_db
}
fn detection_mode(&self) -> DetectionMode {
if self.detection_mode_index == 1 {
DetectionMode::Rms {
window_ms: RMS_WINDOW_MS,
}
} else {
DetectionMode::Peak
}
}
fn rebuild_sidechain_hpf_internal(&mut self) {
let fc = self.sidechain_hpf_hz.max(0.0);
if fc > 0.0 && self.sample_rate > 0 {
let order = match self.sidechain_hpf_order_index {
1 => 4,
_ => 2,
};
let peq = peq_butterworth_highpass(order, fc as f64, self.sample_rate as f64);
let sections: Vec<Biquad> = peq.into_iter().map(|(_, bq)| bq).collect();
self.sidechain_hpf_biquads = (0..self.channels).map(|_| sections.clone()).collect();
} else {
self.sidechain_hpf_biquads.clear();
}
}
fn rebuild_sidechain_tilt_internal(&mut self) {
let tilt = self.sidechain_tilt_db;
if tilt.abs() < 0.01 || self.sample_rate == 0 {
self.sidechain_tilt_biquads.clear();
return;
}
let shelf_freq = 1000.0;
let q = 0.707; self.sidechain_tilt_biquads = (0..self.channels)
.map(|_| {
Biquad::new(
BiquadFilterType::Highshelf,
shelf_freq,
self.sample_rate as f64,
q,
tilt as f64,
)
})
.collect();
}
}
#[inline]
fn time_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
if time_ms <= 0.0 {
0.0
} else {
(-1.0 / (time_ms * 0.001 * sample_rate as f32)).exp()
}
}
#[inline]
fn calculate_compress_gr(input_db: f32, threshold: f32, ratio: f32, knee: f32) -> f32 {
let slope = 1.0 - 1.0 / ratio.max(1.0);
if knee < 0.1 {
if input_db <= threshold {
0.0
} else {
(input_db - threshold) * slope
}
} else if input_db < threshold - knee / 2.0 {
0.0
} else if input_db > threshold + knee / 2.0 {
(input_db - threshold) * slope
} else {
let overshoot = input_db - threshold + knee / 2.0;
let kf = overshoot / knee;
kf * kf * (knee / 2.0) * slope
}
}
#[inline]
fn calculate_expand_atten(
input_db: f32,
threshold: f32,
ratio: f32,
knee: f32,
range_db: f32,
) -> f32 {
let slope = 1.0 - 1.0 / ratio.max(1.0);
let atten = if knee < 0.1 {
if input_db >= threshold {
0.0
} else {
(threshold - input_db) * slope
}
} else if input_db > threshold + knee / 2.0 {
0.0
} else if input_db < threshold - knee / 2.0 {
(threshold - input_db) * slope
} else {
let below = threshold + knee / 2.0 - input_db;
let kf = below / knee;
kf * kf * (knee / 2.0) * slope
};
atten.min(range_db.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 48000;
#[test]
fn test_compress_gain_reduction() {
let core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
let gr = core.calculate_gain_reduction(-30.0, -20.0, 4.0, 0.0);
assert_eq!(gr, 0.0);
let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
assert_eq!(gr, 0.0);
let gr = core.calculate_gain_reduction(-8.0, -20.0, 4.0, 0.0);
assert!((gr - 9.0).abs() < 0.01, "expected ~9.0, got {gr}");
let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 6.0);
assert!(gr > 0.0 && gr < 3.0, "knee GR should be moderate, got {gr}");
}
#[test]
fn test_expand_attenuation() {
let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
core.set_expand_params(3.0, 50.0, 40.0);
let atten = core.calculate_gain_reduction(-10.0, -20.0, 4.0, 0.0);
assert_eq!(atten, 0.0);
let atten = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
assert_eq!(atten, 0.0);
let atten = core.calculate_gain_reduction(-32.0, -20.0, 4.0, 0.0);
assert!((atten - 9.0).abs() < 0.01, "expected ~9.0, got {atten}");
let atten = core.calculate_gain_reduction(-80.0, -20.0, 4.0, 0.0);
assert!(
(atten - 40.0).abs() < 0.01,
"expected range cap at 40.0, got {atten}"
);
}
#[test]
fn test_envelope_attack_release() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
core.set_attack_release(1.0, 50.0);
let mut env = 0.0f32;
for _ in 0..480 {
env = core.apply_envelope(0, 10.0);
}
assert!(
env > 9.0,
"after 10ms attack (1ms time constant), envelope should be near 10.0, got {env}"
);
for _ in 0..24000 {
env = core.apply_envelope(0, 0.0);
}
assert!(
env < 0.1,
"after 500ms release (50ms time constant), envelope should be near 0, got {env}"
);
}
#[test]
fn test_gate_state_machine() {
let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
core.set_expand_params(3.0, 0.0, 40.0); core.set_attack_release(0.1, 50.0);
let threshold = -20.0;
let ratio = 4.0;
let knee = 0.0;
assert_eq!(core.gate_state(0), GateState::Open);
let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
assert_eq!(atten, 0.0);
assert_eq!(core.gate_state(0), GateState::Open);
let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
assert_eq!(atten, 0.0); assert_eq!(core.gate_state(0), GateState::Hold);
let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
assert!(atten > 0.0, "should be expanding now, got {atten}");
assert_eq!(core.gate_state(0), GateState::Closing);
let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
assert_eq!(atten, 0.0);
assert_eq!(core.gate_state(0), GateState::Open);
}
#[test]
fn test_sidechain_hpf() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
core.set_sidechain_hpf(200.0, 0);
let mut low_energy = 0.0f32;
let freq = 50.0;
for i in 0..SR {
let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
let filtered = core.apply_sidechain_filter(0, sample);
low_energy += filtered * filtered;
}
core.set_sidechain_hpf(200.0, 0);
let mut high_energy = 0.0f32;
let freq = 1000.0;
for i in 0..SR {
let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
let filtered = core.apply_sidechain_filter(0, sample);
high_energy += filtered * filtered;
}
assert!(
high_energy > low_energy * 10.0,
"HPF should strongly attenuate 50Hz vs 1kHz: low={low_energy}, high={high_energy}"
);
}
#[test]
fn test_detection_peak_vs_rms() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
core.set_detection_mode(0);
let peak_level = core.detect_level(0, 0.5);
assert!((peak_level - 0.5).abs() < 0.001, "peak should be 0.5");
let peak_neg = core.detect_level(0, -0.5);
assert!(
(peak_neg - 0.5).abs() < 0.001,
"peak of negative should be 0.5"
);
core.set_detection_mode(1);
let window_len = (RMS_WINDOW_MS * 0.001 * SR as f32).round() as usize;
let mut rms_level = 0.0f32;
for _ in 0..window_len + 1 {
rms_level = core.detect_level(0, 0.5);
}
assert!(
(rms_level - 0.5).abs() < 0.05,
"RMS of constant 0.5 should be ~0.5, got {rms_level}"
);
core.set_detection_mode(0);
let peak_half = core.detect_level(0, 1.0);
core.set_detection_mode(1);
for i in 0..window_len + 1 {
let sample = if i % 2 == 0 { 1.0 } else { 0.0 };
rms_level = core.detect_level(0, sample);
}
assert!(
rms_level < peak_half,
"RMS should be less than peak for alternating signal: rms={rms_level}, peak={peak_half}"
);
}
#[test]
fn test_no_allocations_in_hot_path() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
core.set_sidechain_hpf(100.0, 0);
core.set_detection_mode(0);
core.set_attack_release(5.0, 50.0);
for i in 0..10000 {
let sample = (i as f32 * 0.01).sin();
let ch = i % 2;
let filtered = core.apply_sidechain_filter(ch, sample);
let level = core.detect_level(ch, filtered);
let input_db = if level < 1e-10 {
-120.0
} else {
20.0 * level.log10()
};
let gr = core.calculate_gain_reduction(input_db, -20.0, 4.0, 6.0);
let _env = core.apply_envelope(ch, gr);
}
assert!(core.envelope_db(0).is_finite());
assert!(core.envelope_db(1).is_finite());
}
#[test]
fn test_lookahead_process_frame() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
core.set_lookahead_ms(5.0);
let delay = core.lookahead_delay_samples();
assert_eq!(delay, 240);
let mut output = vec![0.0f32; 2];
for frame in 0..240 {
let input = [frame as f32, (frame as f32) * 10.0];
core.lookahead_process_frame(&input, &mut output);
assert_eq!(output[0], 0.0);
assert_eq!(output[1], 0.0);
}
let input = [240.0, 2400.0];
core.lookahead_process_frame(&input, &mut output);
assert!((output[0] - 0.0).abs() < 0.001);
assert!((output[1] - 0.0).abs() < 0.001);
let input = [241.0, 2410.0];
core.lookahead_process_frame(&input, &mut output);
assert!((output[0] - 1.0).abs() < 0.001);
assert!((output[1] - 10.0).abs() < 0.001);
}
#[test]
fn test_reset_clears_state() {
let mut core = DynamicsCore::new(DynamicsMode::Expand, 2, SR);
core.set_expand_params(3.0, 50.0, 40.0);
for _ in 0..1000 {
core.apply_envelope(0, 10.0);
core.apply_envelope(1, 5.0);
}
assert!(core.envelope_db(0) > 0.0);
assert!(core.envelope_db(1) > 0.0);
core.reset();
assert_eq!(core.envelope_db(0), 0.0);
assert_eq!(core.envelope_db(1), 0.0);
assert_eq!(core.gate_state(0), GateState::Open);
assert_eq!(core.gate_state(1), GateState::Open);
}
#[test]
fn test_measured_makeup() {
let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
for _ in 0..480000 {
core.update_measured_makeup(6.0);
}
let makeup_db = core.measured_makeup_db();
assert!(
(makeup_db - 6.0).abs() < 0.1,
"measured makeup should converge to ~6dB, got {makeup_db}"
);
core.reset();
assert!(core.measured_makeup_db().abs() < 0.01);
}
#[test]
fn test_expand_with_gate_state_machine_and_envelope() {
let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
core.set_expand_params(3.0, 0.0, 40.0);
core.set_attack_release(0.1, 10.0);
let threshold = -20.0;
let ratio = 4.0;
let knee = 0.0;
for _ in 0..4800 {
let target = core.process_gate_state(0, -40.0, threshold, ratio, knee);
core.apply_envelope(0, target);
}
let env = core.envelope_db(0);
assert!(
env > 5.0,
"after sustained below-threshold signal, envelope should show attenuation, got {env}"
);
for _ in 0..4800 {
let target = core.process_gate_state(0, -10.0, threshold, ratio, knee);
core.apply_envelope(0, target);
}
let env = core.envelope_db(0);
assert!(
env < 0.5,
"after above-threshold signal, envelope should recover, got {env}"
);
}
}