Skip to main content

wavecraft_dev_server/
host.rs

1//! Development server host implementing ParameterHost trait
2//!
3//! This module provides a ParameterHost implementation for the embedded
4//! development server. It stores parameter values in memory and forwards
5//! parameter changes to an optional AtomicParameterBridge for lock-free
6//! audio-thread access.
7
8#[cfg(feature = "audio")]
9use std::sync::Arc;
10use std::sync::RwLock;
11use std::time::{SystemTime, UNIX_EPOCH};
12use wavecraft_bridge::{BridgeError, InMemoryParameterHost, ParameterHost};
13use wavecraft_protocol::{
14    AudioRuntimePhase, AudioRuntimeStatus, MeterFrame, MeterUpdateNotification, ParameterInfo,
15};
16
17#[cfg(feature = "audio")]
18use crate::audio::atomic_params::AtomicParameterBridge;
19
20/// Development server host for browser-based UI testing
21///
22/// This implementation stores parameter values locally and optionally
23/// forwards updates to an `AtomicParameterBridge` for lock-free reads
24/// on the audio thread. Meter data is provided externally via the audio
25/// server's meter channel (not generated synthetically).
26///
27/// # Thread Safety
28///
29/// Parameter state is protected by RwLock (in `InMemoryParameterHost`).
30/// The `AtomicParameterBridge` uses lock-free atomics for audio thread.
31pub struct DevServerHost {
32    inner: InMemoryParameterHost,
33    latest_meter_frame: Arc<RwLock<Option<MeterFrame>>>,
34    audio_status: Arc<RwLock<AudioRuntimeStatus>>,
35    #[cfg(feature = "audio")]
36    param_bridge: Option<Arc<AtomicParameterBridge>>,
37}
38
39impl DevServerHost {
40    /// Create a new dev server host with parameter metadata.
41    ///
42    /// # Arguments
43    ///
44    /// * `parameters` - Parameter metadata loaded from the plugin FFI
45    ///
46    /// Used by tests and the non-audio build path. When `audio` is
47    /// enabled (default), production code uses `with_param_bridge()` instead.
48    #[cfg_attr(feature = "audio", allow(dead_code))]
49    pub fn new(parameters: Vec<ParameterInfo>) -> Self {
50        let inner = InMemoryParameterHost::new(parameters);
51        let latest_meter_frame = Arc::new(RwLock::new(None));
52        let audio_status = Arc::new(RwLock::new(AudioRuntimeStatus {
53            phase: AudioRuntimePhase::Disabled,
54            diagnostic: None,
55            sample_rate: None,
56            buffer_size: None,
57            updated_at_ms: now_millis(),
58        }));
59
60        Self {
61            inner,
62            latest_meter_frame,
63            audio_status,
64            #[cfg(feature = "audio")]
65            param_bridge: None,
66        }
67    }
68
69    /// Create a new dev server host with an `AtomicParameterBridge`.
70    ///
71    /// When a bridge is provided, `set_parameter()` will write updates
72    /// to both the inner store and the bridge (for audio-thread reads).
73    #[cfg(feature = "audio")]
74    pub fn with_param_bridge(
75        parameters: Vec<ParameterInfo>,
76        bridge: Arc<AtomicParameterBridge>,
77    ) -> Self {
78        let inner = InMemoryParameterHost::new(parameters);
79        let latest_meter_frame = Arc::new(RwLock::new(None));
80        let audio_status = Arc::new(RwLock::new(AudioRuntimeStatus {
81            phase: AudioRuntimePhase::Disabled,
82            diagnostic: None,
83            sample_rate: None,
84            buffer_size: None,
85            updated_at_ms: now_millis(),
86        }));
87
88        Self {
89            inner,
90            latest_meter_frame,
91            audio_status,
92            param_bridge: Some(bridge),
93        }
94    }
95
96    /// Replace all parameters with new metadata from a hot-reload.
97    ///
98    /// Preserves values for parameters with matching IDs. New parameters
99    /// get their default values. This is used by the hot-reload pipeline
100    /// to update parameter definitions without restarting the server.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if parameter replacement fails (e.g., unrecoverable
105    /// lock poisoning).
106    pub fn replace_parameters(&self, new_params: Vec<ParameterInfo>) -> Result<(), String> {
107        self.inner.replace_parameters(new_params)
108    }
109
110    /// Store the latest metering snapshot for polling-based consumers.
111    pub fn set_latest_meter_frame(&self, update: &MeterUpdateNotification) {
112        let mut meter = self
113            .latest_meter_frame
114            .write()
115            .expect("latest_meter_frame lock poisoned");
116        *meter = Some(MeterFrame {
117            peak_l: update.left_peak,
118            peak_r: update.right_peak,
119            rms_l: update.left_rms,
120            rms_r: update.right_rms,
121            timestamp: update.timestamp_us,
122        });
123    }
124
125    /// Update the shared audio runtime status.
126    pub fn set_audio_status(&self, status: AudioRuntimeStatus) {
127        let mut current = self
128            .audio_status
129            .write()
130            .expect("audio_status lock poisoned");
131        *current = status;
132    }
133}
134
135impl ParameterHost for DevServerHost {
136    fn get_parameter(&self, id: &str) -> Option<ParameterInfo> {
137        self.inner.get_parameter(id)
138    }
139
140    fn set_parameter(&self, id: &str, value: f32) -> Result<(), BridgeError> {
141        let result = self.inner.set_parameter(id, value);
142
143        // Forward to atomic bridge for audio-thread access (lock-free)
144        #[cfg(feature = "audio")]
145        if result.is_ok()
146            && let Some(ref bridge) = self.param_bridge
147        {
148            bridge.write(id, value);
149        }
150
151        result
152    }
153
154    fn get_all_parameters(&self) -> Vec<ParameterInfo> {
155        self.inner.get_all_parameters()
156    }
157
158    fn get_meter_frame(&self) -> Option<MeterFrame> {
159        self.latest_meter_frame
160            .read()
161            .expect("latest_meter_frame lock poisoned")
162            .clone()
163    }
164
165    fn request_resize(&self, _width: u32, _height: u32) -> bool {
166        self.inner.request_resize(_width, _height)
167    }
168
169    fn get_audio_status(&self) -> Option<AudioRuntimeStatus> {
170        Some(
171            self.audio_status
172                .read()
173                .expect("audio_status lock poisoned")
174                .clone(),
175        )
176    }
177}
178
179fn now_millis() -> u64 {
180    SystemTime::now()
181        .duration_since(UNIX_EPOCH)
182        .map_or(0, |duration| duration.as_millis() as u64)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use wavecraft_protocol::ParameterType;
189
190    fn test_params() -> Vec<ParameterInfo> {
191        vec![
192            ParameterInfo {
193                id: "gain".to_string(),
194                name: "Gain".to_string(),
195                param_type: ParameterType::Float,
196                value: 0.5,
197                default: 0.5,
198                min: 0.0,
199                max: 1.0,
200                unit: Some("dB".to_string()),
201                group: Some("Input".to_string()),
202            },
203            ParameterInfo {
204                id: "mix".to_string(),
205                name: "Mix".to_string(),
206                param_type: ParameterType::Float,
207                value: 1.0,
208                default: 1.0,
209                min: 0.0,
210                max: 1.0,
211                unit: Some("%".to_string()),
212                group: None,
213            },
214        ]
215    }
216
217    #[test]
218    fn test_get_parameter() {
219        let host = DevServerHost::new(test_params());
220
221        let param = host.get_parameter("gain").expect("should find gain");
222        assert_eq!(param.id, "gain");
223        assert_eq!(param.name, "Gain");
224        assert!((param.value - 0.5).abs() < f32::EPSILON);
225    }
226
227    #[test]
228    fn test_get_parameter_not_found() {
229        let host = DevServerHost::new(test_params());
230        assert!(host.get_parameter("nonexistent").is_none());
231    }
232
233    #[test]
234    fn test_set_parameter() {
235        let host = DevServerHost::new(test_params());
236
237        host.set_parameter("gain", 0.75).expect("should set gain");
238
239        let param = host.get_parameter("gain").expect("should find gain");
240        assert!((param.value - 0.75).abs() < f32::EPSILON);
241    }
242
243    #[test]
244    fn test_set_parameter_invalid_id() {
245        let host = DevServerHost::new(test_params());
246        let result = host.set_parameter("invalid", 0.5);
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn test_set_parameter_out_of_range() {
252        let host = DevServerHost::new(test_params());
253
254        let result = host.set_parameter("gain", 1.5);
255        assert!(result.is_err());
256
257        let result = host.set_parameter("gain", -0.1);
258        assert!(result.is_err());
259    }
260
261    #[test]
262    fn test_get_all_parameters() {
263        let host = DevServerHost::new(test_params());
264
265        let params = host.get_all_parameters();
266        assert_eq!(params.len(), 2);
267        assert!(params.iter().any(|p| p.id == "gain"));
268        assert!(params.iter().any(|p| p.id == "mix"));
269    }
270
271    #[test]
272    fn test_get_meter_frame() {
273        let host = DevServerHost::new(test_params());
274        // Initially no externally provided meter data.
275        assert!(host.get_meter_frame().is_none());
276
277        host.set_latest_meter_frame(&MeterUpdateNotification {
278            timestamp_us: 42,
279            left_peak: 0.9,
280            left_rms: 0.4,
281            right_peak: 0.8,
282            right_rms: 0.3,
283        });
284
285        let frame = host
286            .get_meter_frame()
287            .expect("meter frame should be populated after update");
288        assert!((frame.peak_l - 0.9).abs() < f32::EPSILON);
289        assert!((frame.rms_r - 0.3).abs() < f32::EPSILON);
290        assert_eq!(frame.timestamp, 42);
291    }
292
293    #[test]
294    fn test_audio_status_roundtrip() {
295        let host = DevServerHost::new(test_params());
296
297        let status = AudioRuntimeStatus {
298            phase: AudioRuntimePhase::RunningInputOnly,
299            diagnostic: None,
300            sample_rate: Some(44100.0),
301            buffer_size: Some(512),
302            updated_at_ms: 100,
303        };
304
305        host.set_audio_status(status.clone());
306
307        let stored = host
308            .get_audio_status()
309            .expect("audio status should always be present in dev host");
310        assert_eq!(stored.phase, status.phase);
311        assert_eq!(stored.buffer_size, status.buffer_size);
312    }
313
314    #[tokio::test(flavor = "current_thread")]
315    async fn test_set_audio_status_inside_runtime_does_not_panic() {
316        let host = DevServerHost::new(test_params());
317
318        host.set_audio_status(AudioRuntimeStatus {
319            phase: AudioRuntimePhase::Initializing,
320            diagnostic: None,
321            sample_rate: Some(48000.0),
322            buffer_size: Some(256),
323            updated_at_ms: 200,
324        });
325
326        let stored = host
327            .get_audio_status()
328            .expect("audio status should always be present in dev host");
329        assert_eq!(stored.phase, AudioRuntimePhase::Initializing);
330        assert_eq!(stored.buffer_size, Some(256));
331    }
332}