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}