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