Skip to main content

audio_engine_core/processor/
lockfree_params.rs

1//! Lock-free Parameter Structures
2//!
3//! Provides snapshot-based parameter passing from main thread to audio thread.
4//! This eliminates the need for mutexes in the audio callback, ensuring
5//! that DSP processing is never blocked or skipped due to lock contention.
6//!
7//! # Design Pattern
8//!
9//! Processor parameters are published as immutable snapshots through `ArcSwap`.
10//! Setters patch snapshots with `ArcSwap::rcu`, so concurrent UI/control writes
11//! retry instead of silently overwriting each other's fields. The audio thread
12//! observes either the old or the new complete snapshot, never a mix of fields
13//! from both.
14
15use std::sync::{
16    atomic::{AtomicU64, Ordering},
17    Arc,
18};
19
20use arc_swap::{ArcSwap, Guard};
21use atomic_float::AtomicF64;
22
23struct SharedParams<T> {
24    current: ArcSwap<T>,
25    generation: AtomicU64,
26}
27
28impl<T: Default> SharedParams<T> {
29    fn new() -> Self {
30        Self::from_snapshot(T::default())
31    }
32}
33
34impl<T> SharedParams<T> {
35    fn from_snapshot(snapshot: T) -> Self {
36        Self {
37            current: ArcSwap::new(Arc::new(snapshot)),
38            generation: AtomicU64::new(0),
39        }
40    }
41
42    #[inline]
43    fn load(&self) -> Arc<T> {
44        self.current.load_full()
45    }
46
47    #[inline]
48    fn load_with_generation(&self) -> (Arc<T>, u64) {
49        loop {
50            let before = self.generation.load(Ordering::Acquire);
51            let current = self.current.load_full();
52            let after = self.generation.load(Ordering::Acquire);
53            if before == after {
54                return (current, after);
55            }
56        }
57    }
58
59    #[inline]
60    fn load_if_changed(&self, cached: &Arc<T>) -> Option<Arc<T>> {
61        let current = self.current.load();
62        if std::ptr::eq(&**current, Arc::as_ref(cached)) {
63            None
64        } else {
65            Some(Guard::into_inner(current))
66        }
67    }
68
69    #[inline]
70    fn load_if_changed_since(&self, cached_generation: u64) -> Option<(Arc<T>, u64)> {
71        let generation = self.generation.load(Ordering::Acquire);
72        if generation == cached_generation {
73            None
74        } else {
75            Some((self.current.load_full(), generation))
76        }
77    }
78
79    #[inline]
80    fn publish(&self, snapshot: T) {
81        self.current.store(Arc::new(snapshot));
82        self.generation.fetch_add(1, Ordering::Release);
83    }
84}
85
86impl<T: Clone> SharedParams<T> {
87    #[inline]
88    fn read(&self) -> T {
89        (*self.current.load_full()).clone()
90    }
91
92    #[inline]
93    fn update(&self, mut f: impl FnMut(&mut T)) {
94        self.current.rcu(|current| {
95            let mut snapshot = T::clone(current);
96            f(&mut snapshot);
97            snapshot
98        });
99        self.generation.fetch_add(1, Ordering::Release);
100    }
101}
102
103macro_rules! impl_default_via_new {
104    ($type:ty) => {
105        impl Default for $type {
106            fn default() -> Self {
107                Self::new()
108            }
109        }
110    };
111}
112
113macro_rules! impl_snapshot_accessors {
114    ($snapshot:ty) => {
115        #[inline]
116        pub fn load(&self) -> Arc<$snapshot> {
117            self.shared.load()
118        }
119
120        #[inline]
121        pub fn load_with_generation(&self) -> (Arc<$snapshot>, u64) {
122            self.shared.load_with_generation()
123        }
124
125        #[inline]
126        pub fn load_if_changed(&self, cached: &Arc<$snapshot>) -> Option<Arc<$snapshot>> {
127            self.shared.load_if_changed(cached)
128        }
129
130        #[inline]
131        pub fn load_if_changed_since(
132            &self,
133            cached_generation: u64,
134        ) -> Option<(Arc<$snapshot>, u64)> {
135            self.shared.load_if_changed_since(cached_generation)
136        }
137    };
138}
139
140macro_rules! impl_set_enabled_accessor {
141    () => {
142        #[inline]
143        pub fn set_enabled(&self, enabled: bool) {
144            self.shared.update(|snapshot| {
145                snapshot.enabled = enabled;
146            });
147        }
148    };
149}
150
151macro_rules! impl_enabled_reader {
152    () => {
153        #[inline]
154        pub fn is_enabled(&self) -> bool {
155            self.read().enabled
156        }
157    };
158}
159
160// ============================================================================
161// EQ Parameters
162// ============================================================================
163
164/// EQ band count constant
165pub const EQ_BANDS: usize = 10;
166
167/// EQ parameter snapshot for audio thread
168#[derive(Debug, Clone, Copy)]
169pub struct EqParamsSnapshot {
170    /// Gain for each band in dB
171    pub gains: [f64; EQ_BANDS],
172    /// Whether EQ is enabled
173    pub enabled: bool,
174}
175
176impl Default for EqParamsSnapshot {
177    fn default() -> Self {
178        Self {
179            gains: [0.0; EQ_BANDS],
180            enabled: false,
181        }
182    }
183}
184
185/// EQ parameters published as complete immutable snapshots.
186pub struct AtomicEqParams {
187    shared: SharedParams<EqParamsSnapshot>,
188}
189
190impl AtomicEqParams {
191    /// Create new EQ params with default values
192    pub fn new() -> Self {
193        Self {
194            shared: SharedParams::new(),
195        }
196    }
197
198    /// Publish all EQ parameters as a complete snapshot.
199    pub fn write(&self, gains: &[f64; EQ_BANDS], enabled: bool) {
200        self.shared.publish(EqParamsSnapshot {
201            gains: *gains,
202            enabled,
203        });
204    }
205
206    /// Read the current EQ parameter snapshot.
207    pub fn read(&self) -> EqParamsSnapshot {
208        self.shared.read()
209    }
210
211    impl_snapshot_accessors!(EqParamsSnapshot);
212
213    /// Update a single band gain by patching and publishing a new snapshot.
214    pub fn set_band_gain(&self, band: usize, gain_db: f64) {
215        if band >= EQ_BANDS {
216            return;
217        }
218        self.shared.update(|snap| {
219            snap.gains[band] = gain_db.clamp(-15.0, 15.0);
220        });
221    }
222
223    /// Set enabled state (main thread)
224    pub fn set_enabled(&self, enabled: bool) {
225        self.shared.update(|snap| {
226            snap.enabled = enabled;
227        });
228    }
229
230    // Quick read of enabled state only.
231    impl_enabled_reader!();
232}
233
234impl_default_via_new!(AtomicEqParams);
235
236// ============================================================================
237// Saturation Parameters (Simple Atomic)
238// ============================================================================
239
240/// Saturation type enumeration for lock-free parameter passing.
241///
242/// M-4 fix: Provides bidirectional conversion with SaturationType
243/// from the saturation module, eliminating unsafe string-based mapping.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
245#[repr(u8)]
246pub enum SaturationTypeValue {
247    #[default]
248    Tape = 0,
249    Tube = 1,
250    Transistor = 2,
251}
252
253impl From<u8> for SaturationTypeValue {
254    fn from(v: u8) -> Self {
255        match v {
256            0 => Self::Tape,
257            1 => Self::Tube,
258            2 => Self::Transistor,
259            _ => Self::default(),
260        }
261    }
262}
263
264impl From<crate::processor::SaturationType> for SaturationTypeValue {
265    fn from(st: crate::processor::SaturationType) -> Self {
266        match st {
267            crate::processor::SaturationType::Tape => Self::Tape,
268            crate::processor::SaturationType::Tube => Self::Tube,
269            crate::processor::SaturationType::Transistor => Self::Transistor,
270        }
271    }
272}
273
274impl From<SaturationTypeValue> for crate::processor::SaturationType {
275    fn from(v: SaturationTypeValue) -> Self {
276        match v {
277            SaturationTypeValue::Tape => Self::Tape,
278            SaturationTypeValue::Tube => Self::Tube,
279            SaturationTypeValue::Transistor => Self::Transistor,
280        }
281    }
282}
283
284impl From<super::dsp::NoiseShaperCurve> for u8 {
285    fn from(curve: super::dsp::NoiseShaperCurve) -> Self {
286        match curve {
287            super::dsp::NoiseShaperCurve::Lipshitz5 => 0,
288            super::dsp::NoiseShaperCurve::FWeighted9 => 1,
289            super::dsp::NoiseShaperCurve::ModifiedE9 => 2,
290            super::dsp::NoiseShaperCurve::ImprovedE9 => 3,
291            super::dsp::NoiseShaperCurve::TpdfOnly => 4,
292        }
293    }
294}
295
296impl From<u8> for super::dsp::NoiseShaperCurve {
297    fn from(value: u8) -> Self {
298        match value {
299            0 => super::dsp::NoiseShaperCurve::Lipshitz5,
300            1 => super::dsp::NoiseShaperCurve::FWeighted9,
301            2 => super::dsp::NoiseShaperCurve::ModifiedE9,
302            3 => super::dsp::NoiseShaperCurve::ImprovedE9,
303            4 => super::dsp::NoiseShaperCurve::TpdfOnly,
304            _ => super::dsp::NoiseShaperCurve::Lipshitz5,
305        }
306    }
307}
308
309/// Saturation parameter snapshot
310#[derive(Debug, Clone, Copy)]
311pub struct SaturationParamsSnapshot {
312    pub drive: f64,
313    pub threshold: f64,
314    pub mix: f64,
315    pub sat_type: SaturationTypeValue,
316    pub input_gain_db: f64,
317    pub output_gain_db: f64,
318    pub highpass_mode: bool,
319    pub highpass_cutoff: f64,
320    pub enabled: bool,
321}
322
323impl Default for SaturationParamsSnapshot {
324    fn default() -> Self {
325        Self {
326            drive: 0.25,
327            threshold: 0.88,
328            mix: 0.2,
329            sat_type: SaturationTypeValue::Tube,
330            input_gain_db: 0.0,
331            output_gain_db: 0.0,
332            highpass_mode: false,
333            highpass_cutoff: 4000.0,
334            enabled: true,
335        }
336    }
337}
338
339/// Saturation parameters published as complete immutable snapshots.
340pub struct AtomicSaturationParams {
341    shared: SharedParams<SaturationParamsSnapshot>,
342}
343
344impl AtomicSaturationParams {
345    pub fn new() -> Self {
346        Self {
347            shared: SharedParams::new(),
348        }
349    }
350
351    /// Set drive amount (0.0 - 2.0)
352    #[inline]
353    pub fn set_drive(&self, drive: f64) {
354        self.shared.update(|snapshot| {
355            snapshot.drive = drive.clamp(0.0, 2.0);
356        });
357    }
358
359    /// Set threshold (0.0 - 1.0)
360    #[inline]
361    pub fn set_threshold(&self, threshold: f64) {
362        self.shared.update(|snapshot| {
363            snapshot.threshold = threshold.clamp(0.0, 1.0);
364        });
365    }
366
367    /// Set mix amount (0.0 - 1.0)
368    #[inline]
369    pub fn set_mix(&self, mix: f64) {
370        self.shared.update(|snapshot| {
371            snapshot.mix = mix.clamp(0.0, 1.0);
372        });
373    }
374
375    /// Set saturation type
376    #[inline]
377    pub fn set_sat_type(&self, sat_type: SaturationTypeValue) {
378        self.shared.update(|snapshot| {
379            snapshot.sat_type = sat_type;
380        });
381    }
382
383    /// Set input gain (dB)
384    #[inline]
385    pub fn set_input_gain(&self, gain_db: f64) {
386        self.shared.update(|snapshot| {
387            snapshot.input_gain_db = gain_db;
388        });
389    }
390
391    /// Set output gain (dB)
392    #[inline]
393    pub fn set_output_gain(&self, gain_db: f64) {
394        self.shared.update(|snapshot| {
395            snapshot.output_gain_db = gain_db;
396        });
397    }
398
399    /// Set highpass mode
400    #[inline]
401    pub fn set_highpass_mode(&self, enabled: bool) {
402        self.shared.update(|snapshot| {
403            snapshot.highpass_mode = enabled;
404        });
405    }
406
407    /// Set highpass cutoff frequency
408    #[inline]
409    pub fn set_highpass_cutoff(&self, hz: f64) {
410        self.shared.update(|snapshot| {
411            snapshot.highpass_cutoff = hz.clamp(1000.0, 12000.0);
412        });
413    }
414
415    impl_set_enabled_accessor!();
416
417    /// Read all parameters into a snapshot
418    #[inline]
419    pub fn read(&self) -> SaturationParamsSnapshot {
420        self.shared.read()
421    }
422
423    impl_snapshot_accessors!(SaturationParamsSnapshot);
424
425    // Quick check if enabled.
426    impl_enabled_reader!();
427}
428
429impl_default_via_new!(AtomicSaturationParams);
430
431// ============================================================================
432// Crossfeed Parameters
433// ============================================================================
434
435/// Crossfeed parameter snapshot
436#[derive(Debug, Clone, Copy)]
437pub struct CrossfeedParamsSnapshot {
438    pub mix: f64,
439    pub cutoff_hz: f64,
440    pub enabled: bool,
441}
442
443impl Default for CrossfeedParamsSnapshot {
444    fn default() -> Self {
445        Self {
446            mix: 0.35,
447            cutoff_hz: 700.0,
448            enabled: true,
449        }
450    }
451}
452
453/// Atomic crossfeed parameters
454pub struct AtomicCrossfeedParams {
455    shared: SharedParams<CrossfeedParamsSnapshot>,
456}
457
458impl AtomicCrossfeedParams {
459    pub fn new() -> Self {
460        Self {
461            shared: SharedParams::new(),
462        }
463    }
464
465    #[inline]
466    pub fn set_mix(&self, mix: f64) {
467        self.shared.update(|snapshot| {
468            snapshot.mix = mix.clamp(0.0, 1.0);
469        });
470    }
471
472    #[inline]
473    pub fn set_cutoff(&self, hz: f64) {
474        self.shared.update(|snapshot| {
475            snapshot.cutoff_hz = hz.clamp(200.0, 2000.0);
476        });
477    }
478
479    impl_set_enabled_accessor!();
480
481    #[inline]
482    pub fn read(&self) -> CrossfeedParamsSnapshot {
483        self.shared.read()
484    }
485
486    impl_snapshot_accessors!(CrossfeedParamsSnapshot);
487
488    impl_enabled_reader!();
489}
490
491impl_default_via_new!(AtomicCrossfeedParams);
492
493// ============================================================================
494// Peak Limiter Parameters
495// ============================================================================
496
497/// Peak limiter parameter snapshot
498#[derive(Debug, Clone, Copy)]
499pub struct PeakLimiterParamsSnapshot {
500    pub threshold_db: f64,
501    pub release_ms: f64,
502    pub enabled: bool,
503}
504
505impl Default for PeakLimiterParamsSnapshot {
506    fn default() -> Self {
507        Self {
508            threshold_db: -1.0,
509            release_ms: 150.0,
510            enabled: true,
511        }
512    }
513}
514
515/// Atomic peak limiter parameters
516pub struct AtomicPeakLimiterParams {
517    shared: SharedParams<PeakLimiterParamsSnapshot>,
518}
519
520impl AtomicPeakLimiterParams {
521    pub fn new() -> Self {
522        Self {
523            shared: SharedParams::new(),
524        }
525    }
526
527    #[inline]
528    pub fn set_threshold(&self, db: f64) {
529        self.shared.update(|snapshot| {
530            snapshot.threshold_db = db.clamp(-20.0, 0.0);
531        });
532    }
533
534    #[inline]
535    pub fn set_release(&self, ms: f64) {
536        self.shared.update(|snapshot| {
537            snapshot.release_ms = ms.clamp(10.0, 1000.0);
538        });
539    }
540
541    impl_set_enabled_accessor!();
542
543    #[inline]
544    pub fn read(&self) -> PeakLimiterParamsSnapshot {
545        self.shared.read()
546    }
547
548    impl_snapshot_accessors!(PeakLimiterParamsSnapshot);
549
550    impl_enabled_reader!();
551}
552
553impl_default_via_new!(AtomicPeakLimiterParams);
554
555// ============================================================================
556// Volume Parameters
557// ============================================================================
558
559/// Volume parameter snapshot
560#[derive(Debug, Clone, Copy)]
561pub struct VolumeParamsSnapshot {
562    pub volume: f64, // 0.0 - 1.0
563    pub muted: bool,
564}
565
566impl Default for VolumeParamsSnapshot {
567    fn default() -> Self {
568        Self {
569            volume: 1.0,
570            muted: false,
571        }
572    }
573}
574
575/// Atomic volume parameters
576pub struct AtomicVolumeParams {
577    shared: SharedParams<VolumeParamsSnapshot>,
578}
579
580impl AtomicVolumeParams {
581    pub fn new() -> Self {
582        Self {
583            shared: SharedParams::new(),
584        }
585    }
586
587    /// Set volume (0.0 = silence, 1.0 = full)
588    #[inline]
589    pub fn set_volume(&self, vol: f64) {
590        self.shared.update(|snapshot| {
591            snapshot.volume = vol.clamp(0.0, 1.0);
592        });
593    }
594
595    /// Set mute state
596    #[inline]
597    pub fn set_muted(&self, muted: bool) {
598        self.shared.update(|snapshot| {
599            snapshot.muted = muted;
600        });
601    }
602
603    /// Read current state
604    #[inline]
605    pub fn read(&self) -> VolumeParamsSnapshot {
606        self.shared.read()
607    }
608
609    impl_snapshot_accessors!(VolumeParamsSnapshot);
610
611    /// Get effective volume (0.0 if muted)
612    #[inline]
613    pub fn effective_volume(&self) -> f64 {
614        let snapshot = self.read();
615        if snapshot.muted {
616            0.0
617        } else {
618            snapshot.volume
619        }
620    }
621}
622
623impl_default_via_new!(AtomicVolumeParams);
624
625// ============================================================================
626// Noise Shaper Parameters
627// ============================================================================
628
629/// Noise shaper parameter snapshot
630#[derive(Debug, Clone, Copy)]
631pub struct NoiseShaperParamsSnapshot {
632    pub enabled: bool,
633    pub bits: u32,
634    pub curve: super::dsp::NoiseShaperCurve,
635}
636
637impl Default for NoiseShaperParamsSnapshot {
638    fn default() -> Self {
639        Self {
640            enabled: true,
641            bits: 24,
642            curve: super::dsp::NoiseShaperCurve::Lipshitz5,
643        }
644    }
645}
646
647/// Atomic noise shaper parameters
648pub struct AtomicNoiseShaperParams {
649    shared: SharedParams<NoiseShaperParamsSnapshot>,
650}
651
652impl AtomicNoiseShaperParams {
653    pub fn new() -> Self {
654        Self {
655            shared: SharedParams::new(),
656        }
657    }
658
659    impl_set_enabled_accessor!();
660
661    #[inline]
662    pub fn set_bits(&self, bits: u32) {
663        self.shared.update(|snapshot| {
664            snapshot.bits = bits.clamp(8, 32);
665        });
666    }
667
668    #[inline]
669    pub fn set_curve(&self, curve: super::dsp::NoiseShaperCurve) {
670        self.shared.update(|snapshot| {
671            snapshot.curve = curve;
672        });
673    }
674
675    #[inline]
676    pub fn read(&self) -> NoiseShaperParamsSnapshot {
677        self.shared.read()
678    }
679
680    impl_snapshot_accessors!(NoiseShaperParamsSnapshot);
681
682    impl_enabled_reader!();
683
684    #[inline]
685    pub fn bits(&self) -> u32 {
686        self.read().bits
687    }
688
689    #[inline]
690    pub fn curve(&self) -> super::dsp::NoiseShaperCurve {
691        self.read().curve
692    }
693}
694
695impl_default_via_new!(AtomicNoiseShaperParams);
696
697// ============================================================================
698// Dynamic Loudness Parameters
699// ============================================================================
700
701/// Dynamic loudness parameter snapshot
702#[derive(Debug, Clone, Copy)]
703pub struct DynamicLoudnessParamsSnapshot {
704    pub enabled: bool,
705    pub volume: f64,
706    pub strength: f64,
707    pub ref_volume_db: Option<f64>,
708}
709
710impl Default for DynamicLoudnessParamsSnapshot {
711    fn default() -> Self {
712        Self {
713            enabled: true,
714            volume: 1.0,
715            strength: 1.0,
716            ref_volume_db: None,
717        }
718    }
719}
720
721/// Atomic dynamic loudness parameters
722pub struct AtomicDynamicLoudnessParams {
723    shared: SharedParams<DynamicLoudnessParamsSnapshot>,
724}
725
726impl AtomicDynamicLoudnessParams {
727    pub fn new() -> Self {
728        Self {
729            shared: SharedParams::new(),
730        }
731    }
732
733    impl_set_enabled_accessor!();
734
735    #[inline]
736    pub fn set_volume(&self, vol: f64) {
737        self.shared.update(|snapshot| {
738            snapshot.volume = vol.clamp(0.0, 1.0);
739            snapshot.ref_volume_db = None;
740        });
741    }
742
743    /// Set the reference volume in dB and publish the derived linear volume.
744    #[inline]
745    pub fn set_ref_volume_db(&self, db: f64) {
746        let mut snapshot = self.shared.read();
747        if snapshot.ref_volume_db == Some(db) {
748            return;
749        }
750        snapshot.ref_volume_db = Some(db);
751        // Convert dB to linear (0dB = 1.0, -20dB = 0.1, etc.)
752        snapshot.volume = 10f64.powf(db / 20.0).clamp(0.0, 1.0);
753        self.shared.publish(snapshot);
754    }
755
756    /// Set strength (0.0 - 1.0)
757    #[inline]
758    pub fn set_strength(&self, strength: f64) {
759        self.shared.update(|snapshot| {
760            snapshot.strength = strength.clamp(0.0, 1.0);
761        });
762    }
763
764    #[inline]
765    pub fn read(&self) -> DynamicLoudnessParamsSnapshot {
766        self.shared.read()
767    }
768
769    impl_snapshot_accessors!(DynamicLoudnessParamsSnapshot);
770
771    impl_enabled_reader!();
772
773    /// Get strength (0.0 - 1.0)
774    #[inline]
775    pub fn strength(&self) -> f64 {
776        self.read().strength
777    }
778}
779
780impl_default_via_new!(AtomicDynamicLoudnessParams);
781
782/// Real-time dynamic loudness telemetry published by audio thread.
783///
784/// Exposes the current loudness compensation factor and 7-band gains
785/// for UI/state query without touching real-time processor internals.
786pub struct AtomicDynamicLoudnessTelemetry {
787    factor: AtomicF64,
788    band_gains: [AtomicF64; 7],
789}
790
791impl AtomicDynamicLoudnessTelemetry {
792    pub fn new() -> Self {
793        Self {
794            factor: AtomicF64::new(0.0),
795            band_gains: std::array::from_fn(|_| AtomicF64::new(0.0)),
796        }
797    }
798
799    #[inline]
800    pub fn update(&self, factor: f64, band_gains: [f64; 7]) {
801        self.factor.store(factor, Ordering::Release);
802        for (dst, gain) in self.band_gains.iter().zip(band_gains.iter().copied()) {
803            dst.store(gain, Ordering::Release);
804        }
805    }
806
807    #[inline]
808    pub fn factor(&self) -> f64 {
809        self.factor.load(Ordering::Acquire)
810    }
811
812    #[inline]
813    pub fn band_gains(&self) -> [f64; 7] {
814        let _ = self.factor.load(Ordering::Acquire);
815        std::array::from_fn(|i| self.band_gains[i].load(Ordering::Relaxed))
816    }
817}
818
819impl_default_via_new!(AtomicDynamicLoudnessTelemetry);
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn test_eq_params_write_read() {
827        let params = AtomicEqParams::new();
828        let gains = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
829
830        params.write(&gains, true);
831
832        let snapshot = params.read();
833        for (i, &g) in gains.iter().enumerate() {
834            assert!((snapshot.gains[i] - g).abs() < 1e-10);
835        }
836        assert!(snapshot.enabled);
837    }
838
839    #[test]
840    fn test_saturation_params() {
841        let params = AtomicSaturationParams::new();
842
843        params.set_drive(1.5);
844        params.set_mix(0.7);
845        params.set_enabled(true);
846
847        let snapshot = params.read();
848        assert!((snapshot.drive - 1.5).abs() < 1e-10);
849        assert!((snapshot.mix - 0.7).abs() < 1e-10);
850        assert!(snapshot.enabled);
851    }
852
853    #[test]
854    fn test_simple_param_burst_final_state_visible() {
855        let params = AtomicDynamicLoudnessParams::new();
856        for i in 0..100 {
857            params.set_volume(i as f64 / 100.0);
858            params.set_strength(1.0 - i as f64 / 100.0);
859        }
860
861        let snapshot = params.read();
862        assert!((snapshot.volume - 0.99).abs() < 1e-10);
863        assert!((snapshot.strength - 0.01).abs() < 1e-10);
864        assert!(snapshot.enabled);
865    }
866
867    #[test]
868    fn test_eq_snapshot_publication_keeps_old_and_new_consistent() {
869        let params = AtomicEqParams::new();
870        let old = params.load();
871
872        params.set_band_gain(3, 6.0);
873        let new = params.load();
874
875        assert!(!Arc::ptr_eq(&old, &new));
876        assert_eq!(old.gains, [0.0; EQ_BANDS]);
877        assert!((new.gains[3] - 6.0).abs() < 1e-10);
878        for (index, gain) in new.gains.iter().enumerate() {
879            if index != 3 {
880                assert!((*gain - 0.0).abs() < 1e-10);
881            }
882        }
883    }
884
885    #[test]
886    fn test_dynamic_loudness_ref_volume_db_skips_unchanged_publish() {
887        let params = AtomicDynamicLoudnessParams::new();
888
889        params.set_ref_volume_db(-6.0);
890        let first = params.load();
891
892        params.set_ref_volume_db(-6.0);
893        let second = params.load();
894
895        assert!(Arc::ptr_eq(&first, &second));
896    }
897
898    #[test]
899    fn test_telemetry_band_gains_round_trip() {
900        let telemetry = AtomicDynamicLoudnessTelemetry::new();
901        let gains = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
902
903        telemetry.update(0.5, gains);
904
905        assert!((telemetry.factor() - 0.5).abs() < 1e-10);
906        assert_eq!(telemetry.band_gains(), gains);
907    }
908
909    #[test]
910    fn test_volume_params_muted() {
911        let params = AtomicVolumeParams::new();
912
913        params.set_volume(0.5);
914        assert!((params.effective_volume() - 0.5).abs() < 1e-10);
915
916        params.set_muted(true);
917        assert!((params.effective_volume() - 0.0).abs() < 1e-10);
918    }
919}