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    #[cfg(feature = "audio")]
72    fn bridge_missing_parameter_ids(
73        bridge: &AtomicParameterBridge,
74        parameters: &[ParameterInfo],
75    ) -> Vec<String> {
76        parameters
77            .iter()
78            .filter(|parameter| bridge.read(&parameter.id).is_none())
79            .map(|parameter| parameter.id.clone())
80            .collect()
81    }
82
83    /// Create a new dev server host with parameter metadata.
84    ///
85    /// # Arguments
86    ///
87    /// * `parameters` - Parameter metadata loaded from the plugin FFI
88    ///
89    /// Used by tests and the non-audio build path. When `audio` is
90    /// enabled (default), production code uses `with_param_bridge()` instead.
91    #[cfg_attr(feature = "audio", allow(dead_code))]
92    pub fn new(parameters: Vec<ParameterInfo>) -> Self {
93        let inner = InMemoryParameterHost::new(parameters);
94        let shared_state = Self::initialize_shared_state();
95
96        Self {
97            inner,
98            latest_meter_frame: shared_state.latest_meter_frame,
99            latest_oscilloscope_frame: shared_state.latest_oscilloscope_frame,
100            audio_status: shared_state.audio_status,
101            #[cfg(feature = "audio")]
102            param_bridge: None,
103        }
104    }
105
106    /// Create a new dev server host with an `AtomicParameterBridge`.
107    ///
108    /// When a bridge is provided, `set_parameter()` will write updates
109    /// to both the inner store and the bridge (for audio-thread reads).
110    #[cfg(feature = "audio")]
111    pub fn with_param_bridge(
112        parameters: Vec<ParameterInfo>,
113        bridge: Arc<AtomicParameterBridge>,
114    ) -> Self {
115        let inner = InMemoryParameterHost::new(parameters);
116        let shared_state = Self::initialize_shared_state();
117
118        Self {
119            inner,
120            latest_meter_frame: shared_state.latest_meter_frame,
121            latest_oscilloscope_frame: shared_state.latest_oscilloscope_frame,
122            audio_status: shared_state.audio_status,
123            param_bridge: Some(bridge),
124        }
125    }
126
127    /// Replace all parameters with new metadata from a hot-reload.
128    ///
129    /// Preserves values for parameters with matching IDs. New parameters
130    /// get their default values. This is used by the hot-reload pipeline
131    /// to update parameter definitions without restarting the server.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if parameter replacement fails (e.g., unrecoverable
136    /// lock poisoning).
137    pub fn replace_parameters(&self, new_params: Vec<ParameterInfo>) -> Result<(), String> {
138        #[cfg(feature = "audio")]
139        if let Some(ref bridge) = self.param_bridge {
140            let missing_param_ids = Self::bridge_missing_parameter_ids(bridge, &new_params);
141            if !missing_param_ids.is_empty() {
142                return Err(format!(
143                    "Parameter schema changed during hot-reload, but audio bridge cannot map new IDs yet. Missing IDs in bridge: {}. Restart `wavecraft start` to apply the new parameter schema.",
144                    missing_param_ids.join(", ")
145                ));
146            }
147        }
148
149        self.inner.replace_parameters(new_params)?;
150
151        #[cfg(feature = "audio")]
152        if let Some(ref bridge) = self.param_bridge {
153            for parameter in self.inner.get_all_parameters() {
154                bridge.write(&parameter.id, parameter.value);
155
156                // Keep both legacy and canonical input trim aliases synchronized
157                // across hot-reloads to prevent stale bridge slots.
158                if parameter.id == INPUT_TRIM_LEVEL_PARAM_ID {
159                    bridge.write(LEGACY_INPUT_GAIN_LEVEL_PARAM_ID, parameter.value);
160                } else if parameter.id == LEGACY_INPUT_GAIN_LEVEL_PARAM_ID {
161                    bridge.write(INPUT_TRIM_LEVEL_PARAM_ID, parameter.value);
162                }
163            }
164        }
165
166        Ok(())
167    }
168
169    /// Store the latest metering snapshot for polling-based consumers.
170    pub fn set_latest_meter_frame(&self, update: &MeterUpdateNotification) {
171        let mut meter = self
172            .latest_meter_frame
173            .write()
174            .expect("latest_meter_frame lock poisoned");
175        *meter = Some(MeterFrame {
176            peak_l: update.left_peak,
177            peak_r: update.right_peak,
178            rms_l: update.left_rms,
179            rms_r: update.right_rms,
180            timestamp: update.timestamp_us,
181        });
182    }
183
184    /// Store the latest oscilloscope frame for polling-based consumers.
185    pub fn set_latest_oscilloscope_frame(&self, frame: OscilloscopeFrame) {
186        let mut oscilloscope = self
187            .latest_oscilloscope_frame
188            .write()
189            .expect("latest_oscilloscope_frame lock poisoned");
190        *oscilloscope = Some(frame);
191    }
192
193    /// Update the shared audio runtime status.
194    pub fn set_audio_status(&self, status: AudioRuntimeStatus) {
195        let mut current = self
196            .audio_status
197            .write()
198            .expect("audio_status lock poisoned");
199        *current = status;
200    }
201}
202
203impl ParameterHost for DevServerHost {
204    fn get_parameter(&self, id: &str) -> Option<ParameterInfo> {
205        self.inner.get_parameter(id)
206    }
207
208    fn set_parameter(&self, id: &str, value: f32) -> Result<(), BridgeError> {
209        let result = self.inner.set_parameter(id, value);
210
211        // Forward to atomic bridge for audio-thread access (lock-free)
212        #[cfg(feature = "audio")]
213        if result.is_ok()
214            && let Some(ref bridge) = self.param_bridge
215        {
216            bridge.write(id, value);
217
218            // Hot-reload compatibility: if the parameter model is renamed from
219            // input_gain_level -> input_trim_level (or vice-versa), the bridge
220            // may still contain the old key until full restart. Mirror writes
221            // across both IDs so audible gain control remains live.
222            if id == INPUT_TRIM_LEVEL_PARAM_ID {
223                bridge.write(LEGACY_INPUT_GAIN_LEVEL_PARAM_ID, value);
224            } else if id == LEGACY_INPUT_GAIN_LEVEL_PARAM_ID {
225                bridge.write(INPUT_TRIM_LEVEL_PARAM_ID, value);
226            }
227        }
228
229        result
230    }
231
232    fn get_all_parameters(&self) -> Vec<ParameterInfo> {
233        self.inner.get_all_parameters()
234    }
235
236    fn get_meter_frame(&self) -> Option<MeterFrame> {
237        *self
238            .latest_meter_frame
239            .read()
240            .expect("latest_meter_frame lock poisoned")
241    }
242
243    fn get_oscilloscope_frame(&self) -> Option<OscilloscopeFrame> {
244        self.latest_oscilloscope_frame
245            .read()
246            .expect("latest_oscilloscope_frame lock poisoned")
247            .clone()
248    }
249
250    fn request_resize(&self, width: u32, height: u32) -> bool {
251        self.inner.request_resize(width, height)
252    }
253
254    fn get_audio_status(&self) -> Option<AudioRuntimeStatus> {
255        Some(
256            self.audio_status
257                .read()
258                .expect("audio_status lock poisoned")
259                .clone(),
260        )
261    }
262}
263
264fn now_millis() -> u64 {
265    SystemTime::now()
266        .duration_since(UNIX_EPOCH)
267        .map_or(0, |duration| duration.as_millis() as u64)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use wavecraft_protocol::ParameterType;
274
275    fn test_params() -> Vec<ParameterInfo> {
276        vec![
277            ParameterInfo {
278                id: "gain".to_string(),
279                name: "Gain".to_string(),
280                param_type: ParameterType::Float,
281                value: 0.5,
282                default: 0.5,
283                min: 0.0,
284                max: 1.0,
285                unit: Some("dB".to_string()),
286                group: Some("Input".to_string()),
287                variants: None,
288            },
289            ParameterInfo {
290                id: "mix".to_string(),
291                name: "Mix".to_string(),
292                param_type: ParameterType::Float,
293                value: 1.0,
294                default: 1.0,
295                min: 0.0,
296                max: 1.0,
297                unit: Some("%".to_string()),
298                group: None,
299                variants: None,
300            },
301        ]
302    }
303
304    #[test]
305    fn test_get_parameter() {
306        let host = DevServerHost::new(test_params());
307
308        let param = host.get_parameter("gain").expect("should find gain");
309        assert_eq!(param.id, "gain");
310        assert_eq!(param.name, "Gain");
311        assert!((param.value - 0.5).abs() < f32::EPSILON);
312    }
313
314    #[test]
315    fn test_get_parameter_not_found() {
316        let host = DevServerHost::new(test_params());
317        assert!(host.get_parameter("nonexistent").is_none());
318    }
319
320    #[test]
321    fn test_set_parameter() {
322        let host = DevServerHost::new(test_params());
323
324        host.set_parameter("gain", 0.75).expect("should set gain");
325
326        let param = host.get_parameter("gain").expect("should find gain");
327        assert!((param.value - 0.75).abs() < f32::EPSILON);
328    }
329
330    #[test]
331    fn test_set_parameter_invalid_id() {
332        let host = DevServerHost::new(test_params());
333        let result = host.set_parameter("invalid", 0.5);
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_set_parameter_out_of_range() {
339        let host = DevServerHost::new(test_params());
340
341        let result = host.set_parameter("gain", 1.5);
342        assert!(result.is_err());
343
344        let result = host.set_parameter("gain", -0.1);
345        assert!(result.is_err());
346    }
347
348    #[test]
349    fn test_get_all_parameters() {
350        let host = DevServerHost::new(test_params());
351
352        let params = host.get_all_parameters();
353        assert_eq!(params.len(), 2);
354        assert!(params.iter().any(|p| p.id == "gain"));
355        assert!(params.iter().any(|p| p.id == "mix"));
356    }
357
358    #[test]
359    fn test_get_meter_frame() {
360        let host = DevServerHost::new(test_params());
361        // Initially no externally provided meter data.
362        assert!(host.get_meter_frame().is_none());
363
364        host.set_latest_meter_frame(&MeterUpdateNotification {
365            timestamp_us: 42,
366            left_peak: 0.9,
367            left_rms: 0.4,
368            right_peak: 0.8,
369            right_rms: 0.3,
370        });
371
372        let frame = host
373            .get_meter_frame()
374            .expect("meter frame should be populated after update");
375        assert!((frame.peak_l - 0.9).abs() < f32::EPSILON);
376        assert!((frame.rms_r - 0.3).abs() < f32::EPSILON);
377        assert_eq!(frame.timestamp, 42);
378    }
379
380    #[test]
381    fn test_audio_status_roundtrip() {
382        let host = DevServerHost::new(test_params());
383
384        let status = AudioRuntimeStatus {
385            phase: AudioRuntimePhase::RunningInputOnly,
386            diagnostic: None,
387            sample_rate: Some(44100.0),
388            buffer_size: Some(512),
389            updated_at_ms: 100,
390        };
391
392        host.set_audio_status(status.clone());
393
394        let stored = host
395            .get_audio_status()
396            .expect("audio status should always be present in dev host");
397        assert_eq!(stored.phase, status.phase);
398        assert_eq!(stored.buffer_size, status.buffer_size);
399    }
400
401    #[test]
402    fn test_get_oscilloscope_frame() {
403        let host = DevServerHost::new(test_params());
404        assert!(host.get_oscilloscope_frame().is_none());
405
406        host.set_latest_oscilloscope_frame(OscilloscopeFrame {
407            points_l: vec![0.1; 1024],
408            points_r: vec![0.2; 1024],
409            sample_rate: 48_000.0,
410            timestamp: 777,
411            no_signal: false,
412            trigger_mode: wavecraft_protocol::OscilloscopeTriggerMode::RisingZeroCrossing,
413        });
414
415        let frame = host
416            .get_oscilloscope_frame()
417            .expect("oscilloscope frame should be populated");
418        assert_eq!(frame.points_l.len(), 1024);
419        assert_eq!(frame.points_r.len(), 1024);
420        assert_eq!(frame.timestamp, 777);
421    }
422
423    #[tokio::test(flavor = "current_thread")]
424    async fn test_set_audio_status_inside_runtime_does_not_panic() {
425        let host = DevServerHost::new(test_params());
426
427        host.set_audio_status(AudioRuntimeStatus {
428            phase: AudioRuntimePhase::Initializing,
429            diagnostic: None,
430            sample_rate: Some(48000.0),
431            buffer_size: Some(256),
432            updated_at_ms: 200,
433        });
434
435        let stored = host
436            .get_audio_status()
437            .expect("audio status should always be present in dev host");
438        assert_eq!(stored.phase, AudioRuntimePhase::Initializing);
439        assert_eq!(stored.buffer_size, Some(256));
440    }
441    #[cfg(feature = "audio")]
442    fn soft_clip_bridge_seed_params() -> Vec<ParameterInfo> {
443        vec![
444            ParameterInfo {
445                id: "soft_clip_bypass".to_string(),
446                name: "Bypass".to_string(),
447                param_type: ParameterType::Bool,
448                value: 0.0,
449                default: 0.0,
450                min: 0.0,
451                max: 1.0,
452                unit: None,
453                group: Some("Saturator".to_string()),
454                variants: None,
455            },
456            ParameterInfo {
457                id: "soft_clip_drive_db".to_string(),
458                name: "Drive".to_string(),
459                param_type: ParameterType::Float,
460                value: 12.0,
461                default: 12.0,
462                min: 0.0,
463                max: 30.0,
464                unit: Some("dB".to_string()),
465                group: Some("Saturator".to_string()),
466                variants: None,
467            },
468        ]
469    }
470
471    #[cfg(feature = "audio")]
472    fn soft_clip_expanded_params() -> Vec<ParameterInfo> {
473        vec![
474            ParameterInfo {
475                id: "soft_clip_bypass".to_string(),
476                name: "Bypass".to_string(),
477                param_type: ParameterType::Bool,
478                value: 0.0,
479                default: 0.0,
480                min: 0.0,
481                max: 1.0,
482                unit: None,
483                group: Some("Saturator".to_string()),
484                variants: None,
485            },
486            ParameterInfo {
487                id: "soft_clip_drive_db".to_string(),
488                name: "Drive".to_string(),
489                param_type: ParameterType::Float,
490                value: 12.0,
491                default: 12.0,
492                min: 0.0,
493                max: 30.0,
494                unit: Some("dB".to_string()),
495                group: Some("Saturator".to_string()),
496                variants: None,
497            },
498            ParameterInfo {
499                id: "soft_clip_output_db".to_string(),
500                name: "Output".to_string(),
501                param_type: ParameterType::Float,
502                value: 0.0,
503                default: 0.0,
504                min: -24.0,
505                max: 24.0,
506                unit: Some("dB".to_string()),
507                group: Some("Saturator".to_string()),
508                variants: None,
509            },
510            ParameterInfo {
511                id: "soft_clip_mix".to_string(),
512                name: "Mix".to_string(),
513                param_type: ParameterType::Float,
514                value: 1.0,
515                default: 1.0,
516                min: 0.0,
517                max: 1.0,
518                unit: Some("%".to_string()),
519                group: Some("Saturator".to_string()),
520                variants: None,
521            },
522            ParameterInfo {
523                id: "soft_clip_tone".to_string(),
524                name: "Tone".to_string(),
525                param_type: ParameterType::Float,
526                value: 0.55,
527                default: 0.55,
528                min: 0.0,
529                max: 1.0,
530                unit: Some("%".to_string()),
531                group: Some("Saturator".to_string()),
532                variants: None,
533            },
534        ]
535    }
536
537    #[cfg(feature = "audio")]
538    #[test]
539    fn replace_parameters_rejects_bridge_schema_drift_for_new_soft_clip_controls() {
540        let bridge = Arc::new(AtomicParameterBridge::new(&soft_clip_bridge_seed_params()));
541        let host = DevServerHost::with_param_bridge(soft_clip_bridge_seed_params(), bridge);
542
543        let result = host.replace_parameters(soft_clip_expanded_params());
544        assert!(result.is_err(), "expected schema drift to be rejected");
545
546        let error = result.expect_err("schema drift should return an error");
547        assert!(error.contains("soft_clip_output_db"));
548        assert!(error.contains("soft_clip_mix"));
549        assert!(error.contains("soft_clip_tone"));
550
551        // Existing bridge-backed controls remain available.
552        assert!(host.get_parameter("soft_clip_drive_db").is_some());
553        // New controls should not appear after rejected replacement.
554        assert!(host.get_parameter("soft_clip_output_db").is_none());
555        assert!(host.get_parameter("soft_clip_mix").is_none());
556        assert!(host.get_parameter("soft_clip_tone").is_none());
557    }
558
559    #[cfg(feature = "audio")]
560    #[test]
561    fn replace_parameters_accepts_when_bridge_schema_matches() {
562        let params = soft_clip_expanded_params();
563        let bridge = Arc::new(AtomicParameterBridge::new(&params));
564        let host = DevServerHost::with_param_bridge(params.clone(), bridge);
565
566        host.replace_parameters(params)
567            .expect("matching schema should replace parameters");
568
569        assert!(host.get_parameter("soft_clip_output_db").is_some());
570        assert!(host.get_parameter("soft_clip_mix").is_some());
571        assert!(host.get_parameter("soft_clip_tone").is_some());
572    }
573}