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