Skip to main content

rustriff_lib/services/
audio_service.rs

1use crate::domain::audio_processor::AudioProcessor;
2use crate::domain::channel::Channel;
3use crate::domain::dto::amp_config_dto::AmpConfigDto;
4use crate::domain::dto::effect::effect_dto::EffectDto;
5use crate::infrastructure::audio_handler::{AudioHandler, AudioHandlerTrait};
6use crate::services::analyzers::spectrum_tap::SpectrumTap;
7use crate::services::processors::gain::gain_processor::GainProcessor;
8use crate::services::processors::resampler::resampler::ResamplePolicy;
9use crate::services::processors::tone_stack::tone_stack_processor::ToneStackProcessor;
10use atomic_float::AtomicF32;
11use cpal::{BufferSize, Device, StreamConfig};
12use derive_getters::Getters;
13use ringbuf::consumer::Consumer;
14use ringbuf::producer::Producer;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::thread;
18use std::thread::JoinHandle;
19use std::time::Duration;
20use tracing::{error, info};
21
22const DEFAULT_ANALYZER_SAMPLE_RATE_HZ: u32 = 48_000;
23
24/// The main service that orchestrates real-time audio loopback between an input and output device.
25///
26/// `AudioService` manages the full lifecycle of the audio processing pipeline:
27///
28/// - **Device management** — holds references to the active CPAL input/output devices
29///   through an [`AudioHandlerTrait`] implementation and supports hot-swapping either
30///   device without a full restart.
31/// - **Resampling** — on `start_loopback` the input and output sample rates are compared
32///   and a [`ResamplePolicy`] is selected automatically:
33///   - `input == output` → no resampling, zero overhead.
34///   - `input > output` → downsample before the DSP chain.
35///   - `input < output` → upsample after the DSP chain.
36/// - **DSP chain** — every sample passes through gain, tone stack, and master volume
37///   processors in order. Additional processors can be inserted into `start_loopback`'s
38///   `run_dsp` closure.
39/// - **Thread lifecycle** — the loopback runs on a dedicated background thread with a
40///   lock-free ring buffer between the CPAL callbacks and the worker; the thread is
41///   cleanly shut down via [`stop_loopback`].
42///
43/// [`AudioHandlerTrait`]: crate::infrastructure::audio_handler::AudioHandlerTrait
44/// [`ResamplePolicy`]: crate::services::processors::resampler::resampler::ResamplePolicy
45/// [`stop_loopback`]: AudioService::stop_loopback
46#[derive(Getters)]
47pub struct AudioService {
48    audio_handler: Arc<dyn AudioHandlerTrait>,
49    loopback_thread: Option<JoinHandle<()>>,
50    is_active: bool,
51    channels: Vec<Channel>,
52    current_channel_id: u32,
53    master_volume: Arc<AtomicF32>,
54    next_channel_id: u32,
55    spectrum_tap: Arc<SpectrumTap>,
56}
57
58impl AudioService {
59    /// Returns the sample rate at which the DSP chain effectively runs.
60    ///
61    /// With current resampling policy, DSP executes at the lower of input/output rates.
62    pub fn dsp_chain_sample_rate(&self) -> u32 {
63        self.audio_handler
64            .input_sample_rate()
65            .min(self.audio_handler.output_sample_rate())
66    }
67    /// Creates a new `AudioService` using the provided CPAL input/output devices and stream config.
68    ///
69    /// An [`AudioHandler`] is constructed internally from the given parameters.
70    ///
71    /// # Arguments
72    ///
73    /// * `input_device` - The CPAL device to capture audio from.
74    /// * `output_device` - The CPAL device to send processed audio to.
75    /// * `input_config` - The [`StreamConfig`] used for the input stream.
76    /// * `output_config` - The [`StreamConfig`] used for the output stream.
77    pub fn new(
78        input_device: Device,
79        output_device: Device,
80        input_config: StreamConfig,
81        output_config: StreamConfig,
82    ) -> Self {
83        let handler = AudioHandler::new(input_device, output_device, input_config, output_config);
84        Self::new_with_handler(Arc::new(handler))
85    }
86
87    /// Creates an `AudioService` with a custom handler.
88    ///
89    /// This constructor is primarily intended for unit and integration testing,
90    /// where a mock [`AudioHandlerTrait`] implementation can be injected in place
91    /// of a real [`AudioHandler`].
92    ///
93    /// # Arguments
94    ///
95    /// * `handler` - An [`Arc`]-wrapped implementation of [`AudioHandlerTrait`].
96    pub fn new_with_handler(handler: Arc<dyn AudioHandlerTrait>) -> Self {
97        Self {
98            audio_handler: handler,
99            loopback_thread: None,
100            is_active: false,
101            channels: vec![Channel::new(0, "Default".to_string(), None, None)],
102            master_volume: Arc::new(AtomicF32::new(1.0)),
103            current_channel_id: 0,
104            next_channel_id: 1,
105            // Keep constructor side-effect free for tests using minimal mocks.
106            // Real sample-rate metadata is applied when loopback starts.
107            spectrum_tap: Arc::new(SpectrumTap::new(DEFAULT_ANALYZER_SAMPLE_RATE_HZ)),
108        }
109    }
110
111    /// Starts the audio loopback on a dedicated background thread.
112    ///
113    /// On startup the service:
114    /// 1. Reads the input and output sample rates from the active [`AudioHandlerTrait`].
115    /// 2. Selects a [`ResamplePolicy`] based on those rates (logged at `info` level).
116    /// 3. Builds a pair of lock-free ring buffers sized to the larger of the two rates.
117    /// 4. Asks the handler to open the input and output CPAL streams.
118    /// 5. Spawns a worker thread that:
119    ///    - Pops samples from the input buffer.
120    ///    - Routes them through the [`ResamplePolicy`] which interleaves `run_dsp` at
121    ///      the correct point (before or after resampling).
122    ///    - Pushes results into the output buffer.
123    ///    - On shutdown, flushes any remaining resampler tail before exiting.
124    ///
125    /// If the loopback is already active this method is a no-op.
126    ///
127    /// [`AudioHandlerTrait`]: crate::infrastructure::audio_handler::AudioHandlerTrait
128    /// [`ResamplePolicy`]: crate::services::processors::resampler::resampler::ResamplePolicy
129    pub fn start_loopback(&mut self) {
130        if self.is_active {
131            return;
132        }
133
134        info!("Starting audio loopback");
135        self.is_active = true;
136
137        let handler = self.audio_handler.clone();
138        let channel_id = self.current_channel_id;
139        let master_volume_arc = self.master_volume.clone();
140        let dsp_sample_rate = self.dsp_chain_sample_rate();
141        let spectrum_tap = self.spectrum_tap.clone();
142        spectrum_tap.set_sample_rate_hz(dsp_sample_rate);
143
144        let (gain_arc, volume_arc, tone_stack_arc, effect_chain_arc) = {
145            let channel = self
146                .channels
147                .iter_mut()
148                .find(|c| c.id() == channel_id)
149                .unwrap();
150
151            (
152                channel.gain(),
153                channel.volume(),
154                channel.tone_stack(),
155                channel.effect_chain(),
156            )
157        };
158
159        let thread = thread::spawn(move || {
160            // How many input samples to batch before the resampler produces output.
161            // Larger = better quality, more latency. Smaller = lower latency, cheaper.
162            const RESAMPLER_CHUNK_SIZE: usize = 256;
163
164            let ringbuffer_size = handler
165                .input_sample_rate()
166                .max(handler.output_sample_rate()) as usize;
167
168            // ── Resampling decision ──────────────────────────────────────────────
169            // `ResamplePolicy::from_rates` compares input and output sample rates
170            // and picks one of three strategies (logged at startup):
171            //
172            //   input == output  →  Bypass   – no resampler created at all
173            //   input  > output  →  PreDsp   – downsample BEFORE the DSP chain
174            //                                  (DSP runs at the lower output rate → cheaper)
175            //   input  < output  →  PostDsp  – upsample AFTER the DSP chain
176            //                                  (DSP runs at the lower input rate → cheaper)
177            //
178            // The chosen policy is the only place a `RubatoResampler` is created.
179            let mut policy = ResamplePolicy::from_rates(
180                handler.input_sample_rate(),
181                handler.output_sample_rate(),
182                RESAMPLER_CHUNK_SIZE,
183            );
184
185            let (i_producer, mut i_consumer) = AudioHandler::create_ringbuffer(ringbuffer_size);
186            let (mut o_producer, o_consumer) = AudioHandler::create_ringbuffer(ringbuffer_size);
187
188            let input_stream = handler.build_input_stream(i_producer);
189            let output_stream = handler.build_output_stream(o_consumer);
190
191            let shutdown = Arc::new(AtomicBool::new(false));
192            let worker_shutdown = shutdown.clone();
193
194            let worker = thread::spawn(move || {
195                let mut gain = GainProcessor::new(gain_arc);
196                let mut volume = GainProcessor::new(volume_arc);
197                let mut master_volume = GainProcessor::new(master_volume_arc);
198                let mut tone_stack = ToneStackProcessor::new(tone_stack_arc, dsp_sample_rate);
199
200                let mut run_dsp = |sample: f32| -> f32 {
201                    let sample = gain.process(sample);
202                    let mut sample = tone_stack.process(sample);
203                    if let Ok(mut chain) = effect_chain_arc.lock() {
204                        for effect in chain.iter_mut() {
205                            sample = effect.process_if_active(sample);
206                        }
207                    }
208                    let sample = volume.process(sample);
209                    let sample = master_volume.process(sample);
210                    spectrum_tap.push_sample(sample);
211                    sample
212                };
213
214                loop {
215                    if worker_shutdown.load(Ordering::SeqCst) {
216                        break;
217                    }
218
219                    if let Some(sample) = i_consumer.try_pop() {
220                        // `policy.process` applies the resampler at the right moment:
221                        //   PreDsp  → resamples first, then calls `dsp.process` on each result
222                        //   PostDsp → calls `dsp.process` first, then resamples the output
223                        //   Bypass  → calls `dsp.process` directly, returns a single sample
224                        for processed_sample in policy
225                            .process(sample, &mut |resampled_sample| run_dsp(resampled_sample))
226                        {
227                            let _ = o_producer.try_push(processed_sample);
228                        }
229                    } else {
230                        thread::sleep(Duration::from_millis(1));
231                    }
232                }
233
234                for processed_sample in
235                    policy.flush(&mut |resampled_sample| run_dsp(resampled_sample))
236                {
237                    let _ = o_producer.try_push(processed_sample);
238                }
239            });
240
241            input_stream.play();
242            output_stream.play();
243
244            thread::park();
245
246            shutdown.store(true, Ordering::SeqCst);
247            let _ = worker.join();
248        });
249
250        self.loopback_thread = Some(thread);
251    }
252
253    /// Stops the audio loopback and joins the background thread.
254    ///
255    /// Unparks the loopback thread, signals the inner worker to shut down,
256    /// and waits for both threads to finish. If the loopback is not currently
257    /// active this method is a no-op.
258    pub fn stop_loopback(&mut self) {
259        if !self.is_active {
260            return;
261        }
262
263        info!("Stopping audio loopback");
264
265        if let Some(handle) = self.loopback_thread.take() {
266            handle.thread().unpark();
267            let _ = handle.join();
268        }
269
270        self.is_active = false;
271    }
272
273    /// Sets the master volume value.
274    ///
275    /// The master volume value is atomically updated and will be read by the audio processing
276    /// thread on the next sample cycle.
277    ///
278    /// # Arguments
279    ///
280    /// * `master_volume` - The new master volume value. Must be positive (> 0.0).
281    ///
282    /// # Panics
283    ///
284    /// Panics if `master_volume` is negative or zero.
285    pub fn set_master_volume(&self, master_volume: f32) {
286        if master_volume.is_sign_positive() {
287            self.master_volume.store(master_volume, Ordering::Relaxed);
288        } else {
289            error!("Master volume must be a positive number");
290            panic!("Master volume must be positive");
291        }
292    }
293
294    /// Replaces the underlying audio handler, restarting the loopback if it was running.
295    ///
296    /// If the loopback is active when this method is called it will be stopped,
297    /// the handler swapped, and then the loopback restarted automatically.
298    ///
299    /// # Arguments
300    ///
301    /// * `new_handler` - The replacement [`AudioHandlerTrait`] implementation.
302    pub(crate) fn set_audio_handler(&mut self, new_handler: Arc<dyn AudioHandlerTrait>) {
303        let was_active = self.is_active;
304        if was_active {
305            self.stop_loopback();
306        }
307
308        self.audio_handler = new_handler;
309        self.spectrum_tap
310            .set_sample_rate_hz(self.dsp_chain_sample_rate());
311
312        if was_active {
313            self.start_loopback();
314        }
315    }
316
317    /// Switches the audio input device without interrupting playback longer than necessary.
318    ///
319    /// Constructs a new [`AudioHandler`] that pairs the given `input` device with the
320    /// existing output device and stream config, then delegates to [`set_audio_handler`].
321    ///
322    /// # Arguments
323    ///
324    /// * `input` - The new CPAL input device to capture audio from.
325    ///
326    /// [`set_audio_handler`]: AudioService::set_audio_handler
327    pub fn set_input_device(&mut self, input: Device, input_config: StreamConfig) {
328        info!("Switching input device");
329
330        let old = self.audio_handler.clone();
331        let new_handler = AudioHandler::new(
332            input,
333            old.output_device().clone(),
334            input_config,
335            old.output_config().clone(),
336        );
337
338        self.set_audio_handler(Arc::new(new_handler));
339    }
340
341    /// Switches the audio output device without interrupting playback longer than necessary.
342    ///
343    /// Constructs a new [`AudioHandler`] that pairs the existing input device with the
344    /// given `output` device and stream config, then delegates to [`set_audio_handler`].
345    ///
346    /// # Arguments
347    ///
348    /// * `output` - The new CPAL output device to send processed audio to.
349    ///
350    /// [`set_audio_handler`]: AudioService::set_audio_handler
351    pub fn set_output_device(&mut self, output: Device, output_config: StreamConfig) {
352        info!("Switching output device");
353
354        let old = self.audio_handler.clone();
355        let new_handler = AudioHandler::new(
356            old.input_device().clone(),
357            output,
358            old.input_config().clone(),
359            output_config,
360        );
361
362        self.set_audio_handler(Arc::new(new_handler));
363    }
364
365    /// Toggles the audio loopback on or off.
366    ///
367    /// - If `is_on` is `true` and the loopback is not active, [`start_loopback`] is called.
368    /// - If `is_on` is `false` and the loopback is active, [`stop_loopback`] is called.
369    /// - If the requested state already matches the current state, this method is a no-op.
370    ///
371    /// [`start_loopback`]: AudioService::start_loopback
372    /// [`stop_loopback`]: AudioService::stop_loopback
373    pub fn toggle_loopback(&mut self, is_on: bool) {
374        if self.is_active == is_on {
375            return;
376        }
377        if is_on {
378            self.start_loopback();
379        } else {
380            self.stop_loopback();
381        }
382    }
383
384    /// Adds a new channel to the channel list and return the new channel.
385    ///
386    /// New channels are initialized with default values and the `current_channel_id` is updated to the new channel's id.
387    ///
388    /// # Arguments
389    ///
390    /// * `channel_name` - The name of the new channel (30 characters max).
391    ///
392    /// [`set_current_channel_id`]: AudioService::set_current_channel_id
393    pub fn add_channel(&mut self, channel_name: String) -> u32 {
394        if channel_name.len() <= 30 {
395            let id = self.next_channel_id;
396            self.next_channel_id += 1;
397
398            let new_channel = Channel::new(id, channel_name, None, None);
399
400            self.channels.push(new_channel);
401            self.set_current_channel_id(id);
402            id
403        } else {
404            error!("Channel name must be 30 characters or less");
405            panic!("Channel name must be 30 characters or less");
406        }
407    }
408
409    /// Returns a mutable reference to the channel list, allowing channels to be modified or reordered.
410    pub fn channels_mut(&mut self) -> &mut Vec<Channel> {
411        &mut self.channels
412    }
413
414    /// Removes the channel with the given id from the channel list and sets `current_channel_id` to 0 (default channel).
415    ///
416    /// # Arguments
417    ///
418    /// * `channel_id` - The id of the channel to remove. Cannot be 0 (default channel).
419    ///
420    /// [`set_current_channel_id`]: AudioService::set_current_channel_id
421    pub fn remove_channel(&mut self, channel_id: u32) {
422        if channel_id != 0 {
423            self.channels.retain(|c| c.id() != channel_id);
424            self.set_current_channel_id(0);
425        } else {
426            error!("Cannot remove default channel");
427        }
428    }
429
430    /// Sets the current channel id, restarting the loopback if it was active to ensure the new channel's parameters are applied.
431    ///
432    /// # Arguments
433    ///
434    /// * `new_current_channel_id` - The id of the channel to set as current. Must exist in the channel list.
435    ///
436    /// [`start_loopback`]: AudioService::start_loopback
437    /// [`stop_loopback`]: AudioService::stop_loopback
438    pub fn set_current_channel_id(&mut self, new_current_channel_id: u32) {
439        let was_on = self.is_active;
440        self.stop_loopback();
441        self.current_channel_id = new_current_channel_id;
442        if was_on {
443            self.start_loopback();
444        }
445    }
446
447    /// Returns the current buffer size in frames.
448    ///
449    /// If the handler uses `BufferSize::Default`, returns 256 as a practical fallback.
450    pub fn buffer_size_frames(&self) -> u32 {
451        match self.audio_handler.input_config().buffer_size {
452            BufferSize::Fixed(frames) => frames,
453            BufferSize::Default => 256,
454        }
455    }
456
457    /// Updates the buffer size for both input and output streams.
458    ///
459    /// Rebuilds the audio handler with a `BufferSize::Fixed(frames)` config and
460    /// restarts the loopback if it was active.
461    ///
462    /// # Arguments
463    ///
464    /// * `frames` - The desired buffer size in frames.
465    pub fn set_buffer_size_frames(&mut self, frames: u32) -> Result<(), String> {
466        let old = self.audio_handler.clone();
467        let mut input_config = old.input_config().clone();
468        let mut output_config = old.output_config().clone();
469        input_config.buffer_size = BufferSize::Fixed(frames);
470        output_config.buffer_size = BufferSize::Fixed(frames);
471        let new_handler = AudioHandler::new(
472            old.input_device().clone(),
473            old.output_device().clone(),
474            input_config,
475            output_config,
476        );
477        self.set_audio_handler(std::sync::Arc::new(new_handler));
478        Ok(())
479    }
480
481    /// Applies a persisted amp configuration snapshot to the live service.
482    ///
483    /// Restore behavior summary:
484    /// - channels are recreated from the persisted DTOs,
485    /// - gain, volume, tone stack, and effect-chain state are restored,
486    /// - if the snapshot contains no channels, a default channel is created,
487    /// - if the stored current channel no longer exists, the first restored
488    ///   channel becomes the active channel,
489    /// - `next_channel_id` is recalculated from the restored set,
490    /// - loopback is toggled according to `config.is_active`.
491    ///
492    /// Note that the current JSON persistence implementation always loads with
493    /// `is_active = false`, so persisted sessions restart with loopback turned
494    /// off even though this method is capable of applying either state.
495    pub fn apply_amp_config(&mut self, config: AmpConfigDto) {
496        let mut restored_channels = Vec::new();
497
498        // Backward compatibility: older snapshots stored tone values as 0..100.
499        // New normalized format is 0.0..1.0 end-to-end.
500        let normalize_tone_value = |value: f32| -> f32 {
501            if value > 1.0 {
502                (value / 100.0).clamp(0.0, 1.0)
503            } else {
504                value.clamp(0.0, 1.0)
505            }
506        };
507
508        for channel_dto in config.channels {
509            let mut channel = Channel::new(
510                channel_dto.id,
511                channel_dto.name,
512                Some(channel_dto.gain.max(0.0001)),
513                Some(channel_dto.volume.max(0.0001)),
514            );
515
516            channel.set_bass(normalize_tone_value(channel_dto.tone_stack.bass));
517            channel.set_middle(normalize_tone_value(channel_dto.tone_stack.middle));
518            channel.set_treble(normalize_tone_value(channel_dto.tone_stack.treble));
519
520            let restored_effects = channel_dto
521                .effect_chain
522                .into_iter()
523                .map(|effect_dto: EffectDto| effect_dto.to_domain(self.dsp_chain_sample_rate()))
524                .collect::<Vec<_>>();
525
526            if !restored_effects.is_empty() {
527                channel.restore_effect_chain(restored_effects);
528            }
529            restored_channels.push(channel);
530        }
531
532        if restored_channels.is_empty() {
533            restored_channels.push(Channel::new(0, "Default".to_string(), None, None));
534        }
535
536        let current_channel = if restored_channels
537            .iter()
538            .any(|c| c.id() == config.current_channel)
539        {
540            config.current_channel
541        } else {
542            restored_channels[0].id()
543        };
544
545        self.channels = restored_channels;
546        self.current_channel_id = current_channel;
547        self.next_channel_id = self.channels.iter().map(|c| c.id()).max().unwrap_or(0) + 1;
548        self.master_volume
549            .store(config.master_volume.max(0.0001), Ordering::Relaxed);
550
551        if config.is_active {
552            self.start_loopback();
553        } else {
554            self.stop_loopback();
555        }
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::domain::dto::amp_config_dto::AmpConfigDto;
563    use crate::domain::dto::channel_dto::ChannelDto;
564    use crate::domain::dto::effect::cabinet_dto::CabinetDto;
565    use crate::domain::dto::effect::effect_dto::EffectDto;
566    use crate::domain::dto::effect::hcdistortion_dto::HcDistortionDto;
567    use crate::domain::dto::tone_stack_dto::ToneStackDto;
568    use crate::infrastructure::audio_handler::MockAudioHandlerTrait;
569    use crate::tests::mock::make_mock_handler;
570    use std::sync::atomic::Ordering;
571    use std::sync::Arc;
572
573    fn build_service(handler: MockAudioHandlerTrait) -> AudioService {
574        AudioService::new_with_handler(Arc::new(handler))
575    }
576
577    fn tone_stack(bass: f32, middle: f32, treble: f32) -> ToneStackDto {
578        ToneStackDto {
579            bass,
580            middle,
581            treble,
582        }
583    }
584
585    fn distortion_effect(
586        id: u32,
587        name: &str,
588        is_active: bool,
589        threshold: f32,
590        level: f32,
591        color: &str,
592    ) -> EffectDto {
593        EffectDto::HCDistortion(HcDistortionDto {
594            id,
595            name: name.to_string(),
596            is_active,
597            color: color.to_string(),
598            threshold,
599            level,
600        })
601    }
602
603    fn cabinet_effect(
604        id: u32,
605        name: &str,
606        is_active: bool,
607        color: &str,
608        ir_file_path: &str,
609    ) -> EffectDto {
610        EffectDto::Cabinet(CabinetDto {
611            id,
612            name: name.to_string(),
613            is_active,
614            color: color.to_string(),
615            ir_file_path: ir_file_path.to_string(),
616        })
617    }
618
619    fn channel_dto(
620        id: u32,
621        name: &str,
622        gain: f32,
623        volume: f32,
624        tone_stack: ToneStackDto,
625        effect_chain: Vec<EffectDto>,
626    ) -> ChannelDto {
627        ChannelDto {
628            id,
629            name: name.to_string(),
630            gain,
631            tone_stack,
632            volume,
633            effect_chain,
634        }
635    }
636
637    #[cfg(test)]
638    mod success_path {
639        use super::*;
640
641        #[test]
642        fn master_volume_set_to_positive_value_should_succeed() {
643            let mock = MockAudioHandlerTrait::new();
644            let service = AudioService::new_with_handler(Arc::new(mock));
645            service.set_master_volume(0.5);
646            assert_eq!(service.master_volume().load(Ordering::Relaxed), 0.5);
647        }
648
649        #[test]
650        fn add_channel_should_add_a_channel_with_correct_values_and_sets_current_channel_id_to_new_id(
651        ) {
652            let mock = MockAudioHandlerTrait::new();
653            let mut service = AudioService::new_with_handler(Arc::new(mock));
654            let test_channel_id = service.add_channel("TestChannel".to_string());
655            let test_channel = service
656                .channels
657                .iter()
658                .find(|c| c.id() == test_channel_id)
659                .unwrap();
660
661            assert_eq!(service.channels.len(), 2);
662            assert_eq!(test_channel.name(), "TestChannel");
663            assert_eq!(test_channel.id(), 1);
664            assert_eq!(test_channel.gain().load(Ordering::Relaxed), 1.0);
665            assert_eq!(test_channel.volume().load(Ordering::Relaxed), 1.0);
666            assert_eq!(
667                test_channel.tone_stack().bass().load(Ordering::Relaxed),
668                1.0
669            );
670            assert_eq!(
671                test_channel.tone_stack().middle().load(Ordering::Relaxed),
672                1.0
673            );
674            assert_eq!(
675                test_channel.tone_stack().treble().load(Ordering::Relaxed),
676                1.0
677            );
678            assert_eq!(*service.current_channel_id(), test_channel.id());
679        }
680
681        #[test]
682        fn remove_channel_removes_channel_and_sets_current_channel_id_to_0() {
683            let mock = MockAudioHandlerTrait::new();
684            let mut service = AudioService::new_with_handler(Arc::new(mock));
685            let test_channel_id = service.add_channel("TestChannel".to_string());
686            service.remove_channel(test_channel_id);
687
688            assert_eq!(service.channels.len(), 1);
689            assert_eq!(*service.current_channel_id(), 0);
690        }
691
692        #[test]
693        fn apply_amp_config_restores_channels_tones_effects_and_master_volume() {
694            let mut service = build_service(make_mock_handler());
695            let config = AmpConfigDto {
696                master_volume: 0.42,
697                is_active: false,
698                channels: vec![
699                    channel_dto(4, "Clean", 1.25, 0.8, tone_stack(25.0, 0.45, 130.0), vec![]),
700                    channel_dto(
701                        7,
702                        "Lead",
703                        2.0,
704                        0.65,
705                        tone_stack(0.6, 80.0, -0.5),
706                        vec![distortion_effect(11, "Drive", true, 0.33, 0.7, "#ff6600")],
707                    ),
708                ],
709                current_channel: 7,
710            };
711
712            service.apply_amp_config(config);
713
714            let snapshot = AmpConfigDto::from_service(&service);
715            let clean = snapshot
716                .channels
717                .iter()
718                .find(|channel| channel.id == 4)
719                .unwrap();
720            let lead = snapshot
721                .channels
722                .iter()
723                .find(|channel| channel.id == 7)
724                .unwrap();
725
726            assert_eq!(snapshot.channels.len(), 2);
727            assert_eq!(snapshot.current_channel, 7);
728            assert!(!snapshot.is_active);
729            assert_eq!(service.next_channel_id, 8);
730            assert!((snapshot.master_volume - 0.42).abs() < f32::EPSILON);
731
732            assert_eq!(clean.name, "Clean");
733            assert!((clean.gain - 1.25).abs() < f32::EPSILON);
734            assert!((clean.volume - 0.8).abs() < f32::EPSILON);
735            assert!((clean.tone_stack.bass - 0.25).abs() < 1e-6);
736            assert!((clean.tone_stack.middle - 0.45).abs() < 1e-6);
737            assert!((clean.tone_stack.treble - 1.0).abs() < 1e-6);
738
739            assert_eq!(lead.name, "Lead");
740            assert!((lead.tone_stack.bass - 0.6).abs() < 1e-6);
741            assert!((lead.tone_stack.middle - 0.8).abs() < 1e-6);
742            assert!((lead.tone_stack.treble - 0.0).abs() < 1e-6);
743            assert_eq!(lead.effect_chain.len(), 1);
744            // Compare effect fields individually so floating-point round-trips through
745            // the internal gain mapping (level → 1.0+level → level-1.0) don't fail.
746            if let EffectDto::HCDistortion(dto) = &lead.effect_chain[0] {
747                assert_eq!(dto.id, 11);
748                assert_eq!(dto.name, "Drive");
749                assert!(dto.is_active);
750                assert_eq!(dto.color, "#ff6600");
751                assert!((dto.threshold - 0.33).abs() < 1e-6);
752                assert!((dto.level - 0.7).abs() < 1e-5);
753            } else {
754                panic!("Expected HCDistortion effect");
755            }
756        }
757
758        #[test]
759        fn apply_amp_config_restores_cabinet_effect_ir_file_path() {
760            let mut service = build_service(make_mock_handler());
761            let config = AmpConfigDto {
762                master_volume: 0.8,
763                is_active: false,
764                channels: vec![channel_dto(
765                    2,
766                    "Cab Channel",
767                    1.0,
768                    1.0,
769                    tone_stack(0.5, 0.5, 0.5),
770                    vec![cabinet_effect(9, "Cab", true, "#445566", "Vox-ac30.wav")],
771                )],
772                current_channel: 2,
773            };
774
775            service.apply_amp_config(config);
776
777            let snapshot = AmpConfigDto::from_service(&service);
778            assert_eq!(snapshot.channels.len(), 1);
779            assert_eq!(snapshot.channels[0].effect_chain.len(), 1);
780
781            if let EffectDto::Cabinet(dto) = &snapshot.channels[0].effect_chain[0] {
782                assert_eq!(dto.id, 9);
783                assert_eq!(dto.name, "Cab");
784                assert!(dto.is_active);
785                assert_eq!(dto.color, "#445566");
786                assert_eq!(dto.ir_file_path, "Vox-ac30.wav");
787            } else {
788                panic!("Expected Cabinet effect");
789            }
790        }
791
792        #[test]
793        fn apply_amp_config_clamps_non_positive_levels_and_falls_back_to_first_channel() {
794            let mut service = build_service(make_mock_handler());
795            let config = AmpConfigDto {
796                master_volume: 0.0,
797                is_active: false,
798                channels: vec![channel_dto(
799                    4,
800                    "Crunch",
801                    -2.0,
802                    0.0,
803                    tone_stack(0.2, 0.4, 0.6),
804                    vec![],
805                )],
806                current_channel: 999,
807            };
808
809            service.apply_amp_config(config);
810
811            let channel = service
812                .channels
813                .iter()
814                .find(|channel| channel.id() == 4)
815                .unwrap();
816
817            assert_eq!(service.channels.len(), 1);
818            assert_eq!(*service.current_channel_id(), 4);
819            assert_eq!(service.next_channel_id, 5);
820            assert!((channel.gain().load(Ordering::Relaxed) - 0.0001).abs() < 1e-6);
821            assert!((channel.volume().load(Ordering::Relaxed) - 0.0001).abs() < 1e-6);
822            assert!((service.master_volume().load(Ordering::Relaxed) - 0.0001).abs() < 1e-6);
823        }
824
825        #[test]
826        fn apply_amp_config_with_no_channels_creates_default_channel() {
827            let mut service = build_service(make_mock_handler());
828
829            service.apply_amp_config(AmpConfigDto {
830                master_volume: 0.75,
831                is_active: false,
832                channels: vec![],
833                current_channel: 321,
834            });
835
836            assert_eq!(service.channels.len(), 1);
837            assert_eq!(service.channels[0].id(), 0);
838            assert_eq!(service.channels[0].name(), "Default");
839            assert_eq!(*service.current_channel_id(), 0);
840            assert_eq!(service.next_channel_id, 1);
841            assert!((service.master_volume().load(Ordering::Relaxed) - 0.75).abs() < f32::EPSILON);
842        }
843
844        #[test]
845        fn apply_amp_config_with_active_flag_starts_loopback() {
846            let mut service = build_service(make_mock_handler());
847
848            service.apply_amp_config(AmpConfigDto {
849                master_volume: 0.9,
850                is_active: true,
851                channels: vec![channel_dto(
852                    2,
853                    "Loopback",
854                    1.0,
855                    1.0,
856                    tone_stack(0.5, 0.5, 0.5),
857                    vec![],
858                )],
859                current_channel: 2,
860            });
861
862            assert!(*service.is_active());
863
864            service.stop_loopback();
865
866            assert!(!*service.is_active());
867        }
868    }
869
870    #[cfg(test)]
871    mod failure_path {
872        use super::*;
873
874        #[test]
875        #[should_panic(expected = "Master volume must be positive")]
876        fn master_volume_set_to_negative_value_should_panic() {
877            let mock = MockAudioHandlerTrait::new();
878            let service = AudioService::new_with_handler(Arc::new(mock));
879            service.set_master_volume(-0.5);
880        }
881
882        #[test]
883        fn removing_default_channel_should_do_nothing() {
884            let mock = MockAudioHandlerTrait::new();
885            let mut service = AudioService::new_with_handler(Arc::new(mock));
886            service.remove_channel(0);
887
888            assert_eq!(service.channels.len(), 1);
889        }
890
891        #[test]
892        #[should_panic(expected = "Channel name must be 30 characters or less")]
893        fn add_channel_should_panic_with_to_long_name() {
894            let mock = MockAudioHandlerTrait::new();
895            let mut service = AudioService::new_with_handler(Arc::new(mock));
896            let _test_channel =
897                service.add_channel("Hippopotomonstrosesquippedaliophobia".to_string());
898        }
899    }
900}