Skip to main content

rustriff_lib/services/
audio_latency_measurement_service.rs

1//! Latency-oriented measurement helpers that sit on top of [`AudioService`].
2//!
3//! # What this module measures
4//!
5//! There are three distinct kinds of latency in the signal chain, each exposed
6//! by a different family of functions:
7//!
8//! | Kind | Functions | What it captures |
9//! |---|---|---|
10//! | **CPU execution time** | [`measure_gain_latency`], [`measure_tone_stack_latency`], [`measure_all_dsp_timings`] | How many µs each DSP processor adds per sample of CPU work |
11//! | **Algorithmic latency** | [`measure_all_dsp_algorithmic_latency`] | Inherent sample-delay introduced by an effect's design (lookahead, delay lines, etc.) |
12//! | **I/O buffer latency** | [`measure_buffer_latency`] | Buffering delay imposed by the CPAL input and output stream frame sizes |
13//! | **Round-trip latency** | [`measure_round_trip_latency`] | True end-to-end wall-clock delay measured by injecting an impulse and timing its echo |
14use crate::domain::dto::algorithmic_latency_dto::AlgorithmicLatencyDto;
15use crate::domain::dto::buffer_latency_dto::BufferLatencyDto;
16use crate::domain::dto::execution_timing_dto::ExecutionTimingDto;
17use crate::domain::dto::round_trip_latency_dto::RoundTripLatencyDto;
18use crate::infrastructure::audio_handler::AudioHandlerTrait;
19use crate::services::analyzers::latency_analyzer::LatencyAnalyzer;
20use crate::services::audio_service::AudioService;
21use crate::services::processors::gain::gain_processor::GainProcessor;
22use crate::services::processors::tone_stack::tone_stack_processor::ToneStackProcessor;
23use crate::services::round_trip_latency_session::RoundTripLatencySession;
24use cpal::BufferSize;
25use std::time::Duration;
26
27/// Stateless facade that groups all latency measurement operations.
28///
29/// Every method takes a shared reference to an [`AudioService`] (or a raw
30/// [`AudioHandlerTrait`] for round-trip) so the caller can hold whichever lock
31/// granularity is appropriate.  None of the methods start or stop the loopback.
32///
33/// [`AudioService`]: crate::services::audio_service::AudioService
34/// [`AudioHandlerTrait`]: crate::infrastructure::audio_handler::AudioHandlerTrait
35pub struct AudioLatencyMeasurementService;
36
37impl AudioLatencyMeasurementService {
38    /// Measures the CPU execution cost added by the [`GainProcessor`] in the current channel.
39    ///
40    /// Internally this benchmarks the gain processor against a zero-work passthrough over
41    /// `iterations × block_size` samples and returns the *net* cost — i.e. the passthrough
42    /// baseline is subtracted so the result isolates the processor's own work.
43    ///
44    /// # Arguments
45    ///
46    /// * `audio_service` — Service snapshot used to read the current channel's gain arc.
47    /// * `block_size` — Number of samples per iteration.  Larger values reduce timer
48    ///   overhead noise; 2 048 is the recommended default for command calls.
49    ///
50    /// # Returns
51    ///
52    /// Added execution cost in **microseconds per sample** (µs/sample), clamped to `≥ 0`.
53    ///
54    /// [`GainProcessor`]: crate::services::processors::gain::gain_processor::GainProcessor
55    pub fn measure_gain_latency(audio_service: &AudioService, block_size: usize) -> f64 {
56        let channel = audio_service
57            .channels()
58            .iter()
59            .find(|c| c.id() == *audio_service.current_channel_id())
60            .unwrap();
61        let mut gain = GainProcessor::new(channel.gain().clone());
62        LatencyAnalyzer::measure_effect_added_execution_us(&mut gain, 256, block_size)
63    }
64
65    /// Measures the CPU execution cost added by the [`ToneStackProcessor`] in the current channel.
66    ///
67    /// Uses the same baseline-subtraction methodology as [`measure_gain_latency`]: the result
68    /// is the net cost of the biquad filter chain, not the total wall-clock time per sample.
69    ///
70    /// # Arguments
71    ///
72    /// * `audio_service` — Service snapshot used to read the current channel's tone-stack arc.
73    /// * `block_size` — Number of samples per benchmark iteration.
74    ///
75    /// # Returns
76    ///
77    /// Added execution cost in **microseconds per sample** (µs/sample), clamped to `≥ 0`.
78    ///
79    /// [`ToneStackProcessor`]: crate::services::processors::tone_stack::tone_stack_processor::ToneStackProcessor
80    /// [`measure_gain_latency`]: AudioLatencyMeasurementService::measure_gain_latency
81    pub fn measure_tone_stack_latency(audio_service: &AudioService, block_size: usize) -> f64 {
82        let channel = audio_service
83            .channels()
84            .iter()
85            .find(|c| c.id() == *audio_service.current_channel_id())
86            .unwrap();
87        let mut tone_stack = ToneStackProcessor::new(
88            channel.tone_stack().clone(),
89            audio_service.dsp_chain_sample_rate(),
90        );
91        LatencyAnalyzer::measure_effect_added_execution_us(&mut tone_stack, 256, block_size)
92    }
93
94    /// Measures the CPU execution cost added by the per-channel volume [`GainProcessor`].
95    ///
96    /// The channel volume is a separate gain stage that sits after the tone stack and before
97    /// the master volume in the DSP chain.  Its cost is benchmarked the same way as the
98    /// input gain — baseline-subtracted and clamped to `≥ 0`.
99    ///
100    /// # Arguments
101    ///
102    /// * `audio_service` — Service snapshot used to read the current channel's volume arc.
103    /// * `block_size` — Number of samples per benchmark iteration.
104    ///
105    /// # Returns
106    ///
107    /// Added execution cost in **microseconds per sample** (µs/sample), clamped to `≥ 0`.
108    ///
109    /// [`GainProcessor`]: crate::services::processors::gain::gain_processor::GainProcessor
110    pub fn measure_volume_latency(audio_service: &AudioService, block_size: usize) -> f64 {
111        let channel = audio_service
112            .channels()
113            .iter()
114            .find(|c| c.id() == *audio_service.current_channel_id())
115            .unwrap();
116        let mut volume = GainProcessor::new(channel.volume().clone());
117        LatencyAnalyzer::measure_effect_added_execution_us(&mut volume, 256, block_size)
118    }
119
120    /// Measures the CPU execution cost of every processor in the active DSP chain.
121    ///
122    /// Runs individual benchmarks for all four processors in signal-chain order and
123    /// returns the results as a vector.
124    ///
125    /// # Arguments
126    ///
127    /// * `audio_service` — Service snapshot providing channel and master-volume arcs.
128    /// * `block_size` — Number of samples per benchmark iteration (recommended: 2 048).
129    ///
130    /// # Returns
131    ///
132    /// A `Vec<ExecutionTimingDto>` with exactly four entries, in signal-chain order:
133    ///
134    /// | Index | Processor |
135    /// |---|---|
136    /// | 0 | Gain |
137    /// | 1 | Tone Stack |
138    /// | 2 | Volume |
139    /// | 3 | Master Volume |
140    ///
141    /// [`measure_gain_latency`]: AudioLatencyMeasurementService::measure_gain_latency
142    /// [`measure_tone_stack_latency`]: AudioLatencyMeasurementService::measure_tone_stack_latency
143    /// [`measure_volume_latency`]: AudioLatencyMeasurementService::measure_volume_latency
144    /// [`GainProcessor`]: crate::services::processors::gain::gain_processor::GainProcessor
145    pub fn measure_all_dsp_timings(
146        audio_service: &AudioService,
147        block_size: usize,
148    ) -> Vec<ExecutionTimingDto> {
149        let gain_us = Self::measure_gain_latency(audio_service, block_size);
150        let tone_stack_us = Self::measure_tone_stack_latency(audio_service, block_size);
151        let volume_us = Self::measure_volume_latency(audio_service, block_size);
152        let master_volume_us = {
153            let mut master_volume = GainProcessor::new(audio_service.master_volume().clone());
154            LatencyAnalyzer::measure_effect_added_execution_us(&mut master_volume, 256, block_size)
155        };
156
157        vec![
158            ExecutionTimingDto::new("Gain", gain_us),
159            ExecutionTimingDto::new("Tone Stack", tone_stack_us),
160            ExecutionTimingDto::new("Volume", volume_us),
161            ExecutionTimingDto::new("Master Volume", master_volume_us),
162        ]
163    }
164
165    /// Returns the algorithmic (design-inherent) delay for every processor in the DSP chain.
166    ///
167    /// For the current chain (Gain → Tone Stack → Volume → Master Volume) every processor
168    /// is a sample-by-sample filter with no lookahead or delay line, so all values are **zero**.
169    ///
170    /// # Arguments
171    ///
172    /// * `audio_service` — Used only to read the output sample rate for ms conversion.
173    ///
174    /// # Returns
175    ///
176    /// A `Vec<AlgorithmicLatencyDto>` with exactly four entries (Gain, Tone Stack, Volume,
177    /// Master Volume), each reporting `latency_samples = 0` and `latency_ms = 0.0`.
178    pub fn measure_all_dsp_algorithmic_latency(
179        audio_service: &AudioService,
180    ) -> Vec<AlgorithmicLatencyDto> {
181        let sample_rate_hz = audio_service.audio_handler().output_sample_rate();
182
183        vec![
184            AlgorithmicLatencyDto::new("Gain", 0, sample_rate_hz),
185            AlgorithmicLatencyDto::new("Tone Stack", 0, sample_rate_hz),
186            AlgorithmicLatencyDto::new("Volume", 0, sample_rate_hz),
187            AlgorithmicLatencyDto::new("Master Volume", 0, sample_rate_hz),
188        ]
189    }
190
191    /// Estimates the I/O buffer latency from the current CPAL stream configuration.
192    ///
193    /// Buffer latency is the delay introduced by the hardware frame buffers: each side
194    /// accumulates a full buffer of samples before the driver delivers or accepts them.
195    /// The formula is:
196    ///
197    /// ```text
198    /// latency_ms = (buffer_frames / sample_rate_hz) × 1000
199    /// ```
200    ///
201    /// When CPAL is configured with [`BufferSize::Default`] the actual frame count is
202    /// unknown at runtime.  In that case a conservative fallback of **256 frames** is
203    /// used so the UI can display a practical estimate rather than zero or an error.
204    ///
205    /// # Arguments
206    ///
207    /// * `audio_service` — Used to read both stream configs and sample rates.
208    ///
209    /// # Returns
210    ///
211    /// A [`BufferLatencyDto`] containing `input_buffer_latency_ms`,
212    /// `output_buffer_latency_ms`, and their sum as `total_buffer_latency_ms`.
213    ///
214    /// [`BufferSize::Default`]: cpal::BufferSize::Default
215    /// [`BufferLatencyDto`]: crate::domain::dto::buffer_latency_dto::BufferLatencyDto
216    pub fn measure_buffer_latency(audio_service: &AudioService) -> BufferLatencyDto {
217        const DEFAULT_BUFFER_FRAMES_FALLBACK: u32 = 256;
218
219        let input_frames = match audio_service.audio_handler().input_config().buffer_size {
220            BufferSize::Fixed(frames) => frames,
221            BufferSize::Default => DEFAULT_BUFFER_FRAMES_FALLBACK,
222        };
223
224        let output_frames = match audio_service.audio_handler().output_config().buffer_size {
225            BufferSize::Fixed(frames) => frames,
226            BufferSize::Default => DEFAULT_BUFFER_FRAMES_FALLBACK,
227        };
228
229        let input_ms = (input_frames as f64
230            / audio_service.audio_handler().input_sample_rate() as f64)
231            * 1000.0;
232        let output_ms = (output_frames as f64
233            / audio_service.audio_handler().output_sample_rate() as f64)
234            * 1000.0;
235
236        BufferLatencyDto::new(input_ms, output_ms)
237    }
238
239    /// Measures true end-to-end round-trip latency using a dedicated pair of CPAL streams.
240    ///
241    /// Unlike the other measurement functions this one performs a **real-world, hardware
242    /// measurement** rather than an analytical estimate.  It delegates to
243    /// [`RoundTripLatencySession::run`], which:
244    ///
245    /// 1. Opens its own private input/output CPAL streams — completely separate from the
246    ///    main loopback.
247    /// 2. Warms up the streams for 1.5 s so the OS audio stack stabilises.
248    /// 3. Calibrates a detection threshold from ambient noise.
249    /// 4. Injects three impulses and times how long each takes to return on the input.
250    /// 5. Returns the average of the three round-trip durations.
251    ///
252    /// The caller (`measure_round_trip_latency` Tauri command) is responsible for releasing
253    /// the `Mutex<AudioService>` lock and spawning a dedicated thread *before* calling this
254    /// function, so the main audio engine and UI remain responsive throughout.
255    ///
256    /// # Arguments
257    ///
258    /// * `handler` — The audio I/O factory cloned from [`AudioService`] before the mutex
259    ///   was released.  Used only to open the temporary measurement streams.
260    ///
261    /// # Returns
262    ///
263    /// A [`RoundTripLatencyDto`] with `is_valid = true` and `latency_ms` set on success,
264    /// or `is_valid = false` and a human-readable `error` message on failure.
265    ///
266    /// # Physical requirement
267    ///
268    /// The audio output must be physically (or virtually) looped back into the input for
269    /// the echo to be detectable.  If it is not, the measurement times out and returns an
270    /// error explaining the likely cause.
271    ///
272    /// [`RoundTripLatencySession::run`]: crate::services::round_trip_latency_session::RoundTripLatencySession::run
273    /// [`AudioService`]: crate::services::audio_service::AudioService
274    /// [`RoundTripLatencyDto`]: crate::domain::dto::round_trip_latency_dto::RoundTripLatencyDto
275    pub fn measure_round_trip_latency(handler: &dyn AudioHandlerTrait) -> RoundTripLatencyDto {
276        match RoundTripLatencySession::run(
277            handler,
278            Duration::from_secs(10),
279            Duration::from_millis(1500),
280        ) {
281            Ok(latency_ms) => RoundTripLatencyDto::success(latency_ms),
282            Err(error) => RoundTripLatencyDto::failure(error),
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::infrastructure::audio_handler::MockAudioHandlerTrait;
291    use crate::services::audio_service::AudioService;
292    use cpal::StreamConfig;
293    use std::sync::Arc;
294
295    fn build_service_with_buffer_config(
296        input_rate: u32,
297        output_rate: u32,
298        input_buffer_size: BufferSize,
299        output_buffer_size: BufferSize,
300    ) -> AudioService {
301        let mut mock = MockAudioHandlerTrait::new();
302
303        let input_config = StreamConfig {
304            channels: 1,
305            sample_rate: input_rate,
306            buffer_size: input_buffer_size,
307        };
308
309        let output_config = StreamConfig {
310            channels: 1,
311            sample_rate: output_rate,
312            buffer_size: output_buffer_size,
313        };
314
315        mock.expect_input_sample_rate().return_const(input_rate);
316        mock.expect_output_sample_rate().return_const(output_rate);
317        mock.expect_input_config().return_const(input_config);
318        mock.expect_output_config().return_const(output_config);
319
320        AudioService::new_with_handler(Arc::new(mock))
321    }
322
323    fn assert_approx_eq(actual: f64, expected: f64, epsilon: f64) {
324        assert!(
325            (actual - expected).abs() <= epsilon,
326            "expected {actual} ~= {expected} (epsilon {epsilon})"
327        );
328    }
329
330    #[cfg(test)]
331    mod success_path {
332        use super::*;
333
334        #[test]
335        fn measure_all_dsp_timings_returns_expected_processors() {
336            let service = build_service_with_buffer_config(
337                48_000,
338                48_000,
339                BufferSize::Fixed(256),
340                BufferSize::Fixed(256),
341            );
342
343            let timings = AudioLatencyMeasurementService::measure_all_dsp_timings(&service, 512);
344
345            assert_eq!(timings.len(), 4);
346            assert_eq!(timings[0].processor_name, "Gain");
347            assert_eq!(timings[1].processor_name, "Tone Stack");
348            assert_eq!(timings[2].processor_name, "Volume");
349            assert_eq!(timings[3].processor_name, "Master Volume");
350            assert!(timings
351                .iter()
352                .all(|t| t.execution_us_per_sample.is_finite()));
353            assert!(timings.iter().all(|t| t.execution_us_per_sample >= 0.0));
354        }
355
356        #[test]
357        fn measure_all_dsp_algorithmic_latency_is_zero_for_simple_chain() {
358            let service = build_service_with_buffer_config(
359                48_000,
360                48_000,
361                BufferSize::Fixed(256),
362                BufferSize::Fixed(256),
363            );
364
365            let latency =
366                AudioLatencyMeasurementService::measure_all_dsp_algorithmic_latency(&service);
367
368            assert_eq!(latency.len(), 4);
369            assert_eq!(latency[0].processor_name, "Gain");
370            assert_eq!(latency[1].processor_name, "Tone Stack");
371            assert_eq!(latency[2].processor_name, "Volume");
372            assert_eq!(latency[3].processor_name, "Master Volume");
373            assert!(latency.iter().all(|item| item.latency_samples == 0));
374            assert!(latency.iter().all(|item| item.latency_ms == 0.0));
375        }
376
377        #[test]
378        fn measure_buffer_latency_uses_fixed_buffer_sizes() {
379            let service = build_service_with_buffer_config(
380                48_000,
381                96_000,
382                BufferSize::Fixed(480),
383                BufferSize::Fixed(960),
384            );
385
386            let latency = AudioLatencyMeasurementService::measure_buffer_latency(&service);
387
388            assert_approx_eq(latency.input_buffer_latency_ms, 10.0, 1e-9);
389            assert_approx_eq(latency.output_buffer_latency_ms, 10.0, 1e-9);
390            assert_approx_eq(latency.total_buffer_latency_ms, 20.0, 1e-9);
391        }
392    }
393
394    #[cfg(test)]
395    mod failure_path {
396        use super::*;
397
398        #[test]
399        fn measure_buffer_latency_falls_back_for_default_buffer_size() {
400            let service = build_service_with_buffer_config(
401                48_000,
402                48_000,
403                BufferSize::Default,
404                BufferSize::Default,
405            );
406
407            let latency = AudioLatencyMeasurementService::measure_buffer_latency(&service);
408            let expected_single_side_ms = (256.0 / 48_000.0) * 1000.0;
409
410            assert_approx_eq(
411                latency.input_buffer_latency_ms,
412                expected_single_side_ms,
413                1e-9,
414            );
415            assert_approx_eq(
416                latency.output_buffer_latency_ms,
417                expected_single_side_ms,
418                1e-9,
419            );
420            assert_approx_eq(
421                latency.total_buffer_latency_ms,
422                expected_single_side_ms * 2.0,
423                1e-9,
424            );
425        }
426    }
427}