Skip to main content

wavecraft_dev_server/audio/
server.rs

1//! Audio server for full-duplex audio I/O in dev mode.
2//!
3//! This module provides an audio server that captures microphone input,
4//! processes it through a `DevAudioProcessor` (typically an `FfiProcessor`
5//! loaded from the user's cdylib), and sends the processed audio to the
6//! output device (speakers/headphones). Meter data is communicated back
7//! via a callback channel.
8//!
9//! # Architecture
10//!
11//! ```text
12//! OS Mic → cpal input callback → deinterleave → FfiProcessor::process()
13//!                                                        │
14//!                                              ┌─────────┴──────────┐
15//!                                              │                    │
16//!                                         meter compute      interleave
17//!                                              │               → SPSC ring
18//!                                              ▼                    │
19//!                                        WebSocket broadcast        │
20//!                                                                   ▼
21//!                                              cpal output callback → Speakers
22//! ```
23
24use std::sync::Arc;
25
26use anyhow::{Context, Result};
27use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
28use cpal::{Device, Stream, StreamConfig};
29use wavecraft_processors::{
30    OscilloscopeFrameConsumer, OscilloscopeTap, Waveform, create_oscilloscope_channel,
31    generate_waveform_sample,
32};
33use wavecraft_protocol::MeterUpdateNotification;
34
35use super::atomic_params::AtomicParameterBridge;
36use super::ffi_processor::DevAudioProcessor;
37
38const GAIN_MULTIPLIER_MIN: f32 = 0.0;
39const GAIN_MULTIPLIER_MAX: f32 = 2.0;
40// Strict runtime policy: canonical IDs only (no alias/legacy fallbacks).
41const INPUT_GAIN_PARAM_ID: &str = "input_gain_level";
42const OUTPUT_GAIN_PARAM_ID: &str = "output_gain_level";
43const OSCILLATOR_WAVEFORM_PARAM_ID: &str = "oscillator_waveform";
44
45/// Configuration for audio server.
46#[derive(Debug, Clone)]
47pub struct AudioConfig {
48    /// Desired sample rate (e.g., 44100.0). Falls back to system default.
49    pub sample_rate: f32,
50    /// Buffer size in samples.
51    pub buffer_size: u32,
52}
53
54/// Handle returned by `AudioServer::start()` that keeps both audio
55/// streams alive. Drop this handle to stop audio capture and playback.
56pub struct AudioHandle {
57    _input_stream: Stream,
58    _output_stream: Option<Stream>,
59}
60
61/// Audio server that processes OS input through a `DevAudioProcessor`
62/// and routes the processed audio to the output device.
63pub struct AudioServer {
64    processor: Box<dyn DevAudioProcessor>,
65    config: AudioConfig,
66    input_device: Device,
67    output_device: Device,
68    input_config: StreamConfig,
69    output_config: StreamConfig,
70    param_bridge: Arc<AtomicParameterBridge>,
71}
72
73impl AudioServer {
74    /// Create a new audio server with the given processor, config, and
75    /// parameter bridge for lock-free audio-thread parameter reads.
76    pub fn new(
77        processor: Box<dyn DevAudioProcessor>,
78        config: AudioConfig,
79        param_bridge: Arc<AtomicParameterBridge>,
80    ) -> Result<Self> {
81        let host = cpal::default_host();
82
83        // Input device (required)
84        let input_device = host
85            .default_input_device()
86            .context("No input device available")?;
87        tracing::info!("Using input device: {}", input_device.name()?);
88
89        let supported_input = input_device
90            .default_input_config()
91            .context("Failed to get default input config")?;
92        let input_sample_rate = supported_input.sample_rate().0;
93        tracing::info!("Input sample rate: {} Hz", input_sample_rate);
94        let input_config: StreamConfig = supported_input.into();
95
96        // Output device (required): dev mode expects audible output by default.
97        let output_device = host
98            .default_output_device()
99            .context("No output device available")?;
100
101        match output_device.name() {
102            Ok(name) => tracing::info!("Using output device: {}", name),
103            Err(_) => tracing::info!("Using output device: (unnamed)"),
104        }
105
106        let supported_output = output_device
107            .default_output_config()
108            .context("Failed to get default output config")?;
109        let output_sr = supported_output.sample_rate().0;
110        tracing::info!("Output sample rate: {} Hz", output_sr);
111        if output_sr != input_sample_rate {
112            tracing::warn!(
113                "Input/output sample rate mismatch ({} vs {}). \
114                 Processing at input rate; output device may resample.",
115                input_sample_rate,
116                output_sr
117            );
118        }
119        let output_config: StreamConfig = supported_output.into();
120
121        Ok(Self {
122            processor,
123            config,
124            input_device,
125            output_device,
126            input_config,
127            output_config,
128            param_bridge,
129        })
130    }
131
132    /// Start audio capture, processing, and playback.
133    ///
134    /// Returns an `AudioHandle` that keeps both streams alive, plus a
135    /// `MeterConsumer` for draining meter frames from a lock-free ring
136    /// buffer (RT-safe: no allocations on the audio thread).
137    ///
138    /// Drop the handle to stop audio.
139    pub fn start(
140        mut self,
141    ) -> Result<(
142        AudioHandle,
143        rtrb::Consumer<MeterUpdateNotification>,
144        OscilloscopeFrameConsumer,
145    )> {
146        // Set sample rate from the actual input device config
147        let actual_sample_rate = self.input_config.sample_rate.0 as f32;
148        self.processor.set_sample_rate(actual_sample_rate);
149
150        let mut processor = self.processor;
151        let buffer_size = self.config.buffer_size as usize;
152        let input_channels = self.input_config.channels as usize;
153        let output_channels = self.output_config.channels as usize;
154        let param_bridge = Arc::clone(&self.param_bridge);
155
156        // --- SPSC ring buffer for input→output audio transfer ---
157        // Capacity: buffer_size * num_channels * 4 blocks of headroom.
158        // Data format: interleaved f32 samples (matches cpal output).
159        let ring_capacity = buffer_size * 2 * 4;
160        let (mut ring_producer, mut ring_consumer) = rtrb::RingBuffer::new(ring_capacity);
161
162        // --- SPSC ring buffer for meter data (audio → consumer task) ---
163        // Capacity: 64 frames — sufficient for ~1s at 60 Hz update rate.
164        // Uses rtrb (lock-free, zero-allocation) instead of tokio channels
165        // to maintain real-time safety on the audio thread.
166        let (mut meter_producer, meter_consumer) =
167            rtrb::RingBuffer::<MeterUpdateNotification>::new(64);
168        let (oscilloscope_producer, oscilloscope_consumer) = create_oscilloscope_channel(8);
169        let mut oscilloscope_tap = OscilloscopeTap::with_output(oscilloscope_producer);
170        oscilloscope_tap.set_sample_rate_hz(actual_sample_rate);
171
172        let mut frame_counter = 0u64;
173        let mut oscillator_phase = 0.0_f32;
174
175        // Pre-allocate deinterleaved buffers BEFORE the audio callback.
176        // These are moved into the closure and reused on every invocation,
177        // avoiding heap allocations on the audio thread.
178        let mut left_buf = vec![0.0f32; buffer_size];
179        let mut right_buf = vec![0.0f32; buffer_size];
180
181        // Pre-allocate interleave buffer for writing to the ring buffer.
182        let mut interleave_buf = vec![0.0f32; buffer_size * 2];
183
184        let input_stream = self
185            .input_device
186            .build_input_stream(
187                &self.input_config,
188                move |data: &[f32], _: &cpal::InputCallbackInfo| {
189                    frame_counter += 1;
190
191                    let num_samples = data.len() / input_channels.max(1);
192                    if num_samples == 0 || input_channels == 0 {
193                        return;
194                    }
195
196                    let actual_samples = num_samples.min(left_buf.len());
197                    let left = &mut left_buf[..actual_samples];
198                    let right = &mut right_buf[..actual_samples];
199
200                    // Zero-fill and deinterleave
201                    left.fill(0.0);
202                    right.fill(0.0);
203
204                    for i in 0..actual_samples {
205                        left[i] = data[i * input_channels];
206                        if input_channels > 1 {
207                            right[i] = data[i * input_channels + 1];
208                        } else {
209                            right[i] = left[i];
210                        }
211                    }
212
213                    // Process through the user's DSP (stack-local channel array)
214                    {
215                        let mut channels: [&mut [f32]; 2] = [left, right];
216                        processor.process(&mut channels);
217                    }
218
219                    // Apply runtime output modifiers from lock-free parameters.
220                    // This provides immediate control for source generators in
221                    // browser dev mode while FFI parameter injection is evolving.
222                    apply_output_modifiers(
223                        left,
224                        right,
225                        &param_bridge,
226                        &mut oscillator_phase,
227                        actual_sample_rate,
228                    );
229
230                    // Re-borrow after process()
231                    let left = &left_buf[..actual_samples];
232                    let right = &right_buf[..actual_samples];
233
234                    // Observation-only waveform capture for oscilloscope UI.
235                    oscilloscope_tap.capture_stereo(left, right);
236
237                    // Compute meters from processed output
238                    let (peak_left, rms_left) = compute_peak_and_rms(left);
239                    let (peak_right, rms_right) = compute_peak_and_rms(right);
240
241                    // Send meter update approximately every other callback.
242                    // At 44100 Hz / 512 samples per buffer ≈ 86 callbacks/sec,
243                    // firing every 2nd callback gives ~43 Hz visual updates.
244                    // The WebSocket/UI side already rate-limits display.
245                    if frame_counter.is_multiple_of(2) {
246                        let notification = MeterUpdateNotification {
247                            timestamp_us: frame_counter,
248                            left_peak: peak_left,
249                            left_rms: rms_left,
250                            right_peak: peak_right,
251                            right_rms: rms_right,
252                        };
253                        // Push to lock-free ring buffer — RT-safe, no allocation.
254                        // If the consumer is slow, older frames are silently
255                        // dropped (acceptable for metering data).
256                        let _ = meter_producer.push(notification);
257                    }
258
259                    // Interleave processed stereo audio and write to ring buffer.
260                    // If the ring buffer is full, samples are silently dropped
261                    // (acceptable — temporary glitch, RT-safe).
262                    let interleave = &mut interleave_buf[..actual_samples * 2];
263                    for i in 0..actual_samples {
264                        interleave[i * 2] = left[i];
265                        interleave[i * 2 + 1] = right[i];
266                    }
267
268                    // Write to SPSC ring buffer — non-blocking, lock-free.
269                    for &sample in interleave.iter() {
270                        if ring_producer.push(sample).is_err() {
271                            break;
272                        }
273                    }
274                },
275                |err| {
276                    tracing::error!("Audio input stream error: {}", err);
277                },
278                None,
279            )
280            .context("Failed to build input stream")?;
281
282        input_stream
283            .play()
284            .context("Failed to start input stream")?;
285        tracing::info!("Input stream started");
286
287        // --- Output stream (required) ---
288        let output_stream = self
289            .output_device
290            .build_output_stream(
291                &self.output_config,
292                move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
293                    if output_channels == 0 {
294                        data.fill(0.0);
295                        return;
296                    }
297
298                    // Route stereo frames from the ring into the device layout.
299                    // Underflow is filled with silence.
300                    for frame in data.chunks_mut(output_channels) {
301                        let left = ring_consumer.pop().unwrap_or(0.0);
302                        let right = ring_consumer.pop().unwrap_or(0.0);
303
304                        if output_channels == 1 {
305                            frame[0] = 0.5 * (left + right);
306                            continue;
307                        }
308
309                        frame[0] = left;
310                        frame[1] = right;
311
312                        for channel in frame.iter_mut().skip(2) {
313                            *channel = 0.0;
314                        }
315                    }
316                },
317                |err| {
318                    tracing::error!("Audio output stream error: {}", err);
319                },
320                None,
321            )
322            .context("Failed to build output stream")?;
323
324        output_stream
325            .play()
326            .context("Failed to start output stream")?;
327        tracing::info!("Output stream started");
328
329        tracing::info!("Audio server started in full-duplex (input + output) mode");
330
331        Ok((
332            AudioHandle {
333                _input_stream: input_stream,
334                _output_stream: Some(output_stream),
335            },
336            meter_consumer,
337            oscilloscope_consumer,
338        ))
339    }
340
341    /// Returns true if an output device is available for audio playback.
342    pub fn has_output(&self) -> bool {
343        true
344    }
345}
346
347fn apply_output_modifiers(
348    left: &mut [f32],
349    right: &mut [f32],
350    param_bridge: &AtomicParameterBridge,
351    oscillator_phase: &mut f32,
352    sample_rate: f32,
353) {
354    let input_gain = read_gain_multiplier(param_bridge, INPUT_GAIN_PARAM_ID);
355    let output_gain = read_gain_multiplier(param_bridge, OUTPUT_GAIN_PARAM_ID);
356    let combined_gain = input_gain * output_gain;
357
358    // Temporary dedicated control for sdk-template oscillator source.
359    // 1.0 = on, 0.0 = off.
360    if let Some(enabled) = param_bridge.read("oscillator_enabled")
361        && enabled < 0.5
362    {
363        left.fill(0.0);
364        right.fill(0.0);
365        apply_gain(left, right, combined_gain);
366        return;
367    }
368
369    // Focused dev-mode bridge for sdk-template oscillator parameters while
370    // full generic FFI parameter injection is still being implemented.
371    let oscillator_frequency = param_bridge.read("oscillator_frequency");
372    let oscillator_level = param_bridge.read("oscillator_level");
373    let oscillator_waveform = param_bridge
374        .read(OSCILLATOR_WAVEFORM_PARAM_ID)
375        .unwrap_or(0.0);
376
377    if let (Some(frequency), Some(level)) = (oscillator_frequency, oscillator_level) {
378        if !sample_rate.is_finite() || sample_rate <= 0.0 {
379            apply_gain(left, right, combined_gain);
380            return;
381        }
382
383        let clamped_frequency = if frequency.is_finite() {
384            frequency.clamp(20.0, 5000.0)
385        } else {
386            440.0
387        };
388        let clamped_level = if level.is_finite() {
389            level.clamp(0.0, 1.0)
390        } else {
391            0.0
392        };
393
394        let phase_delta = clamped_frequency / sample_rate;
395        let mut phase = if oscillator_phase.is_finite() {
396            *oscillator_phase
397        } else {
398            0.0
399        };
400        let waveform = Waveform::from_index(oscillator_waveform);
401
402        for (left_sample, right_sample) in left.iter_mut().zip(right.iter_mut()) {
403            let sample = generate_waveform_sample(waveform, phase) * clamped_level;
404            *left_sample = sample;
405            *right_sample = sample;
406
407            phase += phase_delta;
408            if phase >= 1.0 {
409                phase -= phase.floor();
410            }
411        }
412
413        *oscillator_phase = phase;
414    }
415
416    apply_gain(left, right, combined_gain);
417}
418
419fn read_gain_multiplier(param_bridge: &AtomicParameterBridge, id: &str) -> f32 {
420    if let Some(value) = param_bridge.read(id)
421        && value.is_finite()
422    {
423        return value.clamp(GAIN_MULTIPLIER_MIN, GAIN_MULTIPLIER_MAX);
424    }
425
426    1.0
427}
428
429fn compute_peak_and_rms(samples: &[f32]) -> (f32, f32) {
430    let peak = samples
431        .iter()
432        .copied()
433        .fold(0.0f32, |acc, sample| acc.max(sample.abs()));
434    let rms =
435        (samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32).sqrt();
436
437    (peak, rms)
438}
439
440fn apply_gain(left: &mut [f32], right: &mut [f32], gain: f32) {
441    if (gain - 1.0).abs() <= f32::EPSILON {
442        return;
443    }
444
445    for (left_sample, right_sample) in left.iter_mut().zip(right.iter_mut()) {
446        *left_sample *= gain;
447        *right_sample *= gain;
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::apply_output_modifiers;
454    use crate::audio::atomic_params::AtomicParameterBridge;
455    use wavecraft_protocol::{ParameterInfo, ParameterType};
456
457    fn bridge_with_enabled(default_value: f32) -> AtomicParameterBridge {
458        AtomicParameterBridge::new(&[ParameterInfo {
459            id: "oscillator_enabled".to_string(),
460            name: "Enabled".to_string(),
461            param_type: ParameterType::Float,
462            value: default_value,
463            default: default_value,
464            unit: Some("%".to_string()),
465            min: 0.0,
466            max: 1.0,
467            group: Some("Oscillator".to_string()),
468            variants: None,
469        }])
470    }
471
472    #[test]
473    fn output_modifiers_mute_when_oscillator_disabled() {
474        let bridge = bridge_with_enabled(1.0);
475        bridge.write("oscillator_enabled", 0.0);
476
477        let mut left = [0.25_f32, -0.5, 0.75];
478        let mut right = [0.2_f32, -0.4, 0.6];
479        let mut phase = 0.0;
480        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
481
482        assert!(left.iter().all(|s| s.abs() <= f32::EPSILON));
483        assert!(right.iter().all(|s| s.abs() <= f32::EPSILON));
484    }
485
486    #[test]
487    fn output_modifiers_keep_signal_when_oscillator_enabled() {
488        let bridge = bridge_with_enabled(1.0);
489
490        let mut left = [0.25_f32, -0.5, 0.75];
491        let mut right = [0.2_f32, -0.4, 0.6];
492        let mut phase = 0.0;
493        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
494
495        assert_eq!(left, [0.25, -0.5, 0.75]);
496        assert_eq!(right, [0.2, -0.4, 0.6]);
497    }
498
499    fn oscillator_bridge(
500        frequency: f32,
501        level: f32,
502        waveform: f32,
503        enabled: f32,
504        input_gain_level: f32,
505        output_gain_level: f32,
506    ) -> AtomicParameterBridge {
507        AtomicParameterBridge::new(&[
508            ParameterInfo {
509                id: "oscillator_enabled".to_string(),
510                name: "Enabled".to_string(),
511                param_type: ParameterType::Float,
512                value: enabled,
513                default: enabled,
514                unit: Some("%".to_string()),
515                min: 0.0,
516                max: 1.0,
517                group: Some("Oscillator".to_string()),
518                variants: None,
519            },
520            ParameterInfo {
521                id: "oscillator_frequency".to_string(),
522                name: "Frequency".to_string(),
523                param_type: ParameterType::Float,
524                value: frequency,
525                default: frequency,
526                min: 20.0,
527                max: 5_000.0,
528                unit: Some("Hz".to_string()),
529                group: Some("Oscillator".to_string()),
530                variants: None,
531            },
532            ParameterInfo {
533                id: "oscillator_waveform".to_string(),
534                name: "Waveform".to_string(),
535                param_type: ParameterType::Enum,
536                value: waveform,
537                default: waveform,
538                min: 0.0,
539                max: 3.0,
540                unit: None,
541                group: Some("Oscillator".to_string()),
542                variants: Some(vec![
543                    "Sine".to_string(),
544                    "Square".to_string(),
545                    "Saw".to_string(),
546                    "Triangle".to_string(),
547                ]),
548            },
549            ParameterInfo {
550                id: "oscillator_level".to_string(),
551                name: "Level".to_string(),
552                param_type: ParameterType::Float,
553                value: level,
554                default: level,
555                unit: Some("%".to_string()),
556                min: 0.0,
557                max: 1.0,
558                group: Some("Oscillator".to_string()),
559                variants: None,
560            },
561            ParameterInfo {
562                id: "input_gain_level".to_string(),
563                name: "Level".to_string(),
564                param_type: ParameterType::Float,
565                value: input_gain_level,
566                default: input_gain_level,
567                unit: Some("x".to_string()),
568                min: 0.0,
569                max: 2.0,
570                group: Some("InputGain".to_string()),
571                variants: None,
572            },
573            ParameterInfo {
574                id: "output_gain_level".to_string(),
575                name: "Level".to_string(),
576                param_type: ParameterType::Float,
577                value: output_gain_level,
578                default: output_gain_level,
579                unit: Some("x".to_string()),
580                min: 0.0,
581                max: 2.0,
582                group: Some("OutputGain".to_string()),
583                variants: None,
584            },
585        ])
586    }
587
588    #[test]
589    fn output_modifiers_generate_runtime_oscillator_from_frequency_and_level() {
590        let bridge = oscillator_bridge(880.0, 0.75, 0.0, 1.0, 1.0, 1.0);
591        let mut left = [0.0_f32; 128];
592        let mut right = [0.0_f32; 128];
593        let mut phase = 0.0;
594
595        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
596
597        let peak_left = left
598            .iter()
599            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
600        let peak_right = right
601            .iter()
602            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
603
604        assert!(peak_left > 0.2, "expected audible generated oscillator");
605        assert!(peak_right > 0.2, "expected audible generated oscillator");
606        assert_eq!(left, right, "expected in-phase stereo oscillator output");
607        assert!(phase > 0.0, "phase should advance after generation");
608    }
609
610    #[test]
611    fn output_modifiers_level_zero_produces_silence() {
612        let bridge = oscillator_bridge(440.0, 0.0, 0.0, 1.0, 1.0, 1.0);
613        let mut left = [0.1_f32; 64];
614        let mut right = [0.1_f32; 64];
615        let mut phase = 0.0;
616
617        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
618
619        assert!(left.iter().all(|s| s.abs() <= f32::EPSILON));
620        assert!(right.iter().all(|s| s.abs() <= f32::EPSILON));
621    }
622
623    #[test]
624    fn output_modifiers_frequency_change_changes_waveform() {
625        let low_freq_bridge = oscillator_bridge(220.0, 0.5, 0.0, 1.0, 1.0, 1.0);
626        let high_freq_bridge = oscillator_bridge(1760.0, 0.5, 0.0, 1.0, 1.0, 1.0);
627
628        let mut low_left = [0.0_f32; 256];
629        let mut low_right = [0.0_f32; 256];
630        let mut high_left = [0.0_f32; 256];
631        let mut high_right = [0.0_f32; 256];
632
633        let mut low_phase = 0.0;
634        let mut high_phase = 0.0;
635
636        apply_output_modifiers(
637            &mut low_left,
638            &mut low_right,
639            &low_freq_bridge,
640            &mut low_phase,
641            48_000.0,
642        );
643        apply_output_modifiers(
644            &mut high_left,
645            &mut high_right,
646            &high_freq_bridge,
647            &mut high_phase,
648            48_000.0,
649        );
650
651        assert_ne!(
652            low_left, high_left,
653            "frequency updates should alter waveform"
654        );
655        assert_eq!(low_left, low_right);
656        assert_eq!(high_left, high_right);
657    }
658
659    #[test]
660    fn output_modifiers_apply_input_and_output_gain_levels() {
661        let unity_bridge = oscillator_bridge(880.0, 0.5, 0.0, 1.0, 1.0, 1.0);
662        let boosted_bridge = oscillator_bridge(880.0, 0.5, 0.0, 1.0, 1.5, 2.0);
663
664        let mut unity_left = [0.0_f32; 256];
665        let mut unity_right = [0.0_f32; 256];
666        let mut boosted_left = [0.0_f32; 256];
667        let mut boosted_right = [0.0_f32; 256];
668
669        let mut unity_phase = 0.0;
670        let mut boosted_phase = 0.0;
671
672        apply_output_modifiers(
673            &mut unity_left,
674            &mut unity_right,
675            &unity_bridge,
676            &mut unity_phase,
677            48_000.0,
678        );
679        apply_output_modifiers(
680            &mut boosted_left,
681            &mut boosted_right,
682            &boosted_bridge,
683            &mut boosted_phase,
684            48_000.0,
685        );
686
687        let unity_peak = unity_left
688            .iter()
689            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
690        let boosted_peak = boosted_left
691            .iter()
692            .fold(0.0_f32, |acc, sample| acc.max(sample.abs()));
693
694        assert!(boosted_peak > unity_peak * 2.5);
695        assert_eq!(boosted_left, boosted_right);
696        assert_eq!(unity_left, unity_right);
697    }
698
699    #[test]
700    fn output_modifiers_waveform_change_changes_shape() {
701        let sine_bridge = oscillator_bridge(440.0, 0.5, 0.0, 1.0, 1.0, 1.0);
702        let saw_bridge = oscillator_bridge(440.0, 0.5, 2.0, 1.0, 1.0, 1.0);
703
704        let mut sine_left = [0.0_f32; 256];
705        let mut sine_right = [0.0_f32; 256];
706        let mut saw_left = [0.0_f32; 256];
707        let mut saw_right = [0.0_f32; 256];
708
709        let mut sine_phase = 0.0;
710        let mut saw_phase = 0.0;
711
712        apply_output_modifiers(
713            &mut sine_left,
714            &mut sine_right,
715            &sine_bridge,
716            &mut sine_phase,
717            48_000.0,
718        );
719        apply_output_modifiers(
720            &mut saw_left,
721            &mut saw_right,
722            &saw_bridge,
723            &mut saw_phase,
724            48_000.0,
725        );
726
727        assert_ne!(
728            sine_left, saw_left,
729            "waveform selection should change output shape"
730        );
731        assert_eq!(sine_left, sine_right);
732        assert_eq!(saw_left, saw_right);
733    }
734
735    #[test]
736    fn output_modifiers_apply_gain_without_oscillator_params() {
737        let bridge = AtomicParameterBridge::new(&[
738            ParameterInfo {
739                id: "input_gain_level".to_string(),
740                name: "Level".to_string(),
741                param_type: ParameterType::Float,
742                value: 1.5,
743                default: 1.5,
744                unit: Some("x".to_string()),
745                min: 0.0,
746                max: 2.0,
747                group: Some("InputGain".to_string()),
748                variants: None,
749            },
750            ParameterInfo {
751                id: "output_gain_level".to_string(),
752                name: "Level".to_string(),
753                param_type: ParameterType::Float,
754                value: 1.2,
755                default: 1.2,
756                unit: Some("x".to_string()),
757                min: 0.0,
758                max: 2.0,
759                group: Some("OutputGain".to_string()),
760                variants: None,
761            },
762        ]);
763
764        let mut left = [0.25_f32, -0.5, 0.75];
765        let mut right = [0.2_f32, -0.4, 0.6];
766        let mut phase = 0.0;
767
768        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
769
770        let expected_gain = 1.5 * 1.2;
771        assert_eq!(
772            left,
773            [
774                0.25 * expected_gain,
775                -0.5 * expected_gain,
776                0.75 * expected_gain
777            ]
778        );
779        assert_eq!(
780            right,
781            [
782                0.2 * expected_gain,
783                -0.4 * expected_gain,
784                0.6 * expected_gain
785            ]
786        );
787    }
788
789    #[test]
790    fn output_modifiers_ignore_compact_legacy_gain_ids() {
791        let bridge = AtomicParameterBridge::new(&[
792            ParameterInfo {
793                id: "inputgain_level".to_string(),
794                name: "Level".to_string(),
795                param_type: ParameterType::Float,
796                value: 0.2,
797                default: 0.2,
798                unit: Some("x".to_string()),
799                min: 0.0,
800                max: 2.0,
801                group: Some("InputGain".to_string()),
802                variants: None,
803            },
804            ParameterInfo {
805                id: "outputgain_level".to_string(),
806                name: "Level".to_string(),
807                param_type: ParameterType::Float,
808                value: 0.2,
809                default: 0.2,
810                unit: Some("x".to_string()),
811                min: 0.0,
812                max: 2.0,
813                group: Some("OutputGain".to_string()),
814                variants: None,
815            },
816        ]);
817
818        let mut left = [0.5_f32; 16];
819        let mut right = [0.5_f32; 16];
820        let mut phase = 0.0;
821
822        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
823
824        // Legacy compact IDs are intentionally unsupported.
825        let expected = 0.5;
826        assert!(left.iter().all(|sample| (*sample - expected).abs() < 1e-6));
827        assert!(right.iter().all(|sample| (*sample - expected).abs() < 1e-6));
828    }
829
830    #[test]
831    fn output_modifiers_ignore_legacy_snake_case_gain_suffix_ids() {
832        let bridge = AtomicParameterBridge::new(&[
833            ParameterInfo {
834                id: "input_gain_gain".to_string(),
835                name: "Gain".to_string(),
836                param_type: ParameterType::Float,
837                value: 0.2,
838                default: 0.2,
839                unit: Some("x".to_string()),
840                min: 0.0,
841                max: 2.0,
842                group: Some("InputGain".to_string()),
843                variants: None,
844            },
845            ParameterInfo {
846                id: "output_gain_gain".to_string(),
847                name: "Gain".to_string(),
848                param_type: ParameterType::Float,
849                value: 0.2,
850                default: 0.2,
851                unit: Some("x".to_string()),
852                min: 0.0,
853                max: 2.0,
854                group: Some("OutputGain".to_string()),
855                variants: None,
856            },
857        ]);
858
859        let mut left = [0.5_f32; 16];
860        let mut right = [0.5_f32; 16];
861        let mut phase = 0.0;
862
863        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
864
865        // Legacy "*_gain" aliases are intentionally unsupported.
866        let expected = 0.5;
867        assert!(left.iter().all(|sample| (*sample - expected).abs() < 1e-6));
868        assert!(right.iter().all(|sample| (*sample - expected).abs() < 1e-6));
869    }
870
871    #[test]
872    fn output_modifiers_use_canonical_ids_even_when_legacy_variants_exist() {
873        let bridge = AtomicParameterBridge::new(&[
874            ParameterInfo {
875                id: "input_gain_level".to_string(),
876                name: "Level".to_string(),
877                param_type: ParameterType::Float,
878                value: 1.6,
879                default: 1.6,
880                unit: Some("x".to_string()),
881                min: 0.0,
882                max: 2.0,
883                group: Some("InputGain".to_string()),
884                variants: None,
885            },
886            ParameterInfo {
887                id: "inputgain_level".to_string(),
888                name: "Level".to_string(),
889                param_type: ParameterType::Float,
890                value: 0.4,
891                default: 0.4,
892                unit: Some("x".to_string()),
893                min: 0.0,
894                max: 2.0,
895                group: Some("InputGain".to_string()),
896                variants: None,
897            },
898            ParameterInfo {
899                id: "output_gain_level".to_string(),
900                name: "Level".to_string(),
901                param_type: ParameterType::Float,
902                value: 1.0,
903                default: 1.0,
904                unit: Some("x".to_string()),
905                min: 0.0,
906                max: 2.0,
907                group: Some("OutputGain".to_string()),
908                variants: None,
909            },
910        ]);
911
912        let mut left = [0.5_f32; 8];
913        let mut right = [0.5_f32; 8];
914        let mut phase = 0.0;
915
916        apply_output_modifiers(&mut left, &mut right, &bridge, &mut phase, 48_000.0);
917
918        // Strict canonical-only policy: legacy variants are ignored when present.
919        let expected = 0.5 * 1.6;
920        assert!(left.iter().all(|sample| (*sample - expected).abs() < 1e-6));
921        assert!(right.iter().all(|sample| (*sample - expected).abs() < 1e-6));
922    }
923}