1#[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
26pub 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(¶meter.id).is_none())
79 .map(|parameter| parameter.id.clone())
80 .collect()
81 }
82
83 #[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 #[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 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(¶meter.id, parameter.value);
155
156 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 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 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 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 #[cfg(feature = "audio")]
213 if result.is_ok()
214 && let Some(ref bridge) = self.param_bridge
215 {
216 bridge.write(id, value);
217
218 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 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 assert!(host.get_parameter("soft_clip_drive_db").is_some());
553 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(¶ms));
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}