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