Skip to main content

wavecraft_dev_server/audio/
atomic_params.rs

1//! Lock-free parameter bridge for audio thread access.
2//!
3//! Provides `AtomicParameterBridge` — a collection of `AtomicF32` values keyed
4//! by parameter ID. The WebSocket thread writes parameter updates via `store()`,
5//! and the audio thread reads them via `load()` with zero allocations and zero
6//! locks. Relaxed ordering is sufficient because parameter updates are not
7//! synchronization points; a one-block delay in propagation is acceptable.
8
9use atomic_float::AtomicF32;
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::sync::atomic::Ordering;
13use wavecraft_protocol::ParameterInfo;
14
15const PARAM_ORDERING: Ordering = Ordering::Relaxed;
16
17/// Lock-free bridge for passing parameter values from the WebSocket thread
18/// to the audio thread.
19///
20/// Constructed once at startup with one `Arc<AtomicF32>` per parameter. The
21/// inner `HashMap` is never mutated after construction — only the atomic
22/// values change. This makes reads fully lock-free and real-time safe.
23pub struct AtomicParameterBridge {
24    params: HashMap<String, Arc<AtomicF32>>,
25}
26
27impl AtomicParameterBridge {
28    /// Create a new bridge from parameter metadata.
29    ///
30    /// Each parameter gets an `AtomicF32` initialized to its default value.
31    pub fn new(parameters: &[ParameterInfo]) -> Self {
32        let params = parameters
33            .iter()
34            .map(|p| (p.id.clone(), Arc::new(AtomicF32::new(p.default))))
35            .collect();
36        Self { params }
37    }
38
39    /// Write a parameter value (called from WebSocket thread).
40    ///
41    /// Uses `Ordering::Relaxed` — no synchronization guarantee beyond
42    /// eventual visibility. The audio thread will see the update at the
43    /// next block boundary.
44    pub fn write(&self, id: &str, value: f32) {
45        if let Some(atomic) = self.lookup_param(id) {
46            atomic.store(value, PARAM_ORDERING);
47        }
48    }
49
50    /// Read a parameter value (called from audio thread — RT-safe).
51    ///
52    /// Returns `None` if the parameter ID is unknown. Uses
53    /// `Ordering::Relaxed` — single atomic load, no allocation.
54    pub fn read(&self, id: &str) -> Option<f32> {
55        self.lookup_param(id)
56            .map(|atomic| atomic.load(PARAM_ORDERING))
57    }
58
59    fn lookup_param(&self, id: &str) -> Option<&Arc<AtomicF32>> {
60        self.params.get(id)
61    }
62}
63
64// Compile-time assertion: AtomicParameterBridge is Send + Sync.
65// The HashMap is immutable after construction, and all values are
66// accessed through atomic operations. Arc<AtomicF32> is Send + Sync,
67// so Rust auto-derives these traits correctly.
68const _: () = {
69    fn _assert_send_sync<T: Send + Sync>() {}
70    fn _check() {
71        _assert_send_sync::<AtomicParameterBridge>();
72    }
73};
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use wavecraft_protocol::ParameterType;
79
80    fn test_params() -> Vec<ParameterInfo> {
81        vec![
82            ParameterInfo {
83                id: "gain".to_string(),
84                name: "Gain".to_string(),
85                param_type: ParameterType::Float,
86                value: 0.5,
87                default: 0.5,
88                min: 0.0,
89                max: 1.0,
90                unit: Some("dB".to_string()),
91                group: Some("Input".to_string()),
92                variants: None,
93            },
94            ParameterInfo {
95                id: "mix".to_string(),
96                name: "Mix".to_string(),
97                param_type: ParameterType::Float,
98                value: 1.0,
99                default: 1.0,
100                min: 0.0,
101                max: 1.0,
102                unit: Some("%".to_string()),
103                group: None,
104                variants: None,
105            },
106        ]
107    }
108
109    #[test]
110    fn test_default_values() {
111        let bridge = AtomicParameterBridge::new(&test_params());
112
113        let gain = bridge.read("gain").expect("gain should exist");
114        assert!(
115            (gain - 0.5).abs() < f32::EPSILON,
116            "gain default should be 0.5"
117        );
118
119        let mix = bridge.read("mix").expect("mix should exist");
120        assert!(
121            (mix - 1.0).abs() < f32::EPSILON,
122            "mix default should be 1.0"
123        );
124    }
125
126    #[test]
127    fn test_write_and_read() {
128        let bridge = AtomicParameterBridge::new(&test_params());
129
130        bridge.write("gain", 0.75);
131        let gain = bridge.read("gain").expect("gain should exist");
132        assert!(
133            (gain - 0.75).abs() < f32::EPSILON,
134            "gain should be updated to 0.75"
135        );
136    }
137
138    #[test]
139    fn test_read_unknown_param() {
140        let bridge = AtomicParameterBridge::new(&test_params());
141        assert!(
142            bridge.read("nonexistent").is_none(),
143            "unknown param should return None"
144        );
145    }
146
147    #[test]
148    fn test_write_unknown_param_is_noop() {
149        let bridge = AtomicParameterBridge::new(&test_params());
150        // Should not panic
151        bridge.write("nonexistent", 0.5);
152    }
153
154    #[test]
155    fn test_concurrent_write_read() {
156        use std::sync::Arc;
157        use std::thread;
158
159        let bridge = Arc::new(AtomicParameterBridge::new(&test_params()));
160
161        let writer = {
162            let bridge = Arc::clone(&bridge);
163            thread::spawn(move || {
164                for i in 0..1000 {
165                    bridge.write("gain", i as f32 / 1000.0);
166                }
167            })
168        };
169
170        let reader = {
171            let bridge = Arc::clone(&bridge);
172            thread::spawn(move || {
173                for _ in 0..1000 {
174                    let val = bridge.read("gain");
175                    assert!(val.is_some(), "gain should always be readable");
176                    let v = val.unwrap();
177                    assert!(
178                        (0.0..=1.0).contains(&v) || v == 0.5,
179                        "value should be in range"
180                    );
181                }
182            })
183        };
184
185        writer.join().expect("writer thread should not panic");
186        reader.join().expect("reader thread should not panic");
187    }
188}