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