rustriff_lib/commands/latency_testing.rs
1//! Tauri commands that expose latency measurement to the frontend.
2//!
3//! Each command in this module is a thin adapter over
4//! [`AudioLatencyMeasurementService`]. They are responsible for:
5//!
6//! - Locking (or releasing) the [`AudioService`] mutex at the right time.
7//! - Logging results via `tracing` so they appear in the backend console.
8//! - Returning serialisable DTOs across the IPC boundary.
9//!
10//! # Command overview
11//!
12//! | Command | Tauri invoke name | What it measures |
13//! |---|---|---|
14//! | [`test_gain_latency`] | `test_gain_latency` | Gain processor CPU cost (logs only, no return value) |
15//! | [`measure_all_dsp_cpu_timings`] | `measure_all_dsp_cpu_timings` | CPU cost of all DSP processors |
16//! | [`measure_all_dsp_algorithmic_latency`] | `measure_all_dsp_algorithmic_latency` | Algorithmic (design) delay per processor |
17//! | [`measure_buffer_latency`] | `measure_buffer_latency` | I/O buffer delay from stream config |
18//! | [`measure_round_trip_latency`] | `measure_round_trip_latency` | True end-to-end hardware round-trip |
19//!
20//! [`AudioLatencyMeasurementService`]: crate::services::audio_latency_measurement_service::AudioLatencyMeasurementService
21//! [`AudioService`]: crate::services::audio_service::AudioService
22
23use crate::domain::dto::algorithmic_latency_dto::AlgorithmicLatencyDto;
24use crate::domain::dto::buffer_latency_dto::BufferLatencyDto;
25use crate::domain::dto::execution_timing_dto::ExecutionTimingDto;
26use crate::domain::dto::round_trip_latency_dto::RoundTripLatencyDto;
27use crate::services::audio_latency_measurement_service::AudioLatencyMeasurementService;
28use crate::services::audio_service::AudioService;
29use std::sync::Mutex;
30use tracing::info;
31
32/// Measures the CPU execution impact of the [`GainProcessor`] and logs the result.
33///
34/// This command is intended for quick developer diagnostics — it prints the measurement
35/// to the backend log but does **not** return a value to the frontend. Use
36/// [`measure_all_dsp_cpu_timings`] when you need a structured result.
37///
38/// The measurement uses a block size of 2 048 samples, which is large enough to
39/// suppress timer-call overhead while completing in well under a second.
40///
41/// # Errors
42///
43/// Returns `Err` if the [`AudioService`] mutex cannot be locked.
44///
45/// [`GainProcessor`]: crate::services::processors::gain::gain_processor::GainProcessor
46/// [`AudioService`]: crate::services::audio_service::AudioService
47#[tauri::command]
48pub fn test_gain_latency(
49 audio_service: tauri::State<'_, Mutex<AudioService>>,
50) -> Result<(), String> {
51 let service = audio_service
52 .lock()
53 .map_err(|_| "Failed to lock audio service".to_string())?;
54
55 let added_us_per_sample = AudioLatencyMeasurementService::measure_gain_latency(&service, 2048);
56
57 info!(
58 "Gain processor execution impact: {:.6} µs/sample",
59 added_us_per_sample
60 );
61 println!(
62 "Gain processor execution impact: {:.6} µs/sample",
63 added_us_per_sample
64 );
65
66 Ok(())
67}
68
69/// Measures the CPU execution cost of every processor in the active DSP chain.
70///
71/// Benchmarks the Gain, Tone Stack, and Master Volume processors in signal-chain order
72/// using a block size of 2 048 samples. Each result is the *net* added cost relative
73/// to a zero-work passthrough, clamped to ≥ 0 µs/sample.
74///
75/// Results are both logged at `info` level and returned to the frontend as a
76/// `Vec<ExecutionTimingDto>`.
77///
78/// # Returns
79///
80/// `Ok(timings)` — a vector of exactly three [`ExecutionTimingDto`] entries in
81/// signal-chain order: Gain → Tone Stack → Master Volume.
82///
83/// # Errors
84///
85/// Returns `Err` if the [`AudioService`] mutex cannot be locked.
86///
87/// [`ExecutionTimingDto`]: crate::domain::dto::execution_timing_dto::ExecutionTimingDto
88/// [`AudioService`]: crate::services::audio_service::AudioService
89#[tauri::command]
90pub fn measure_all_dsp_cpu_timings(
91 audio_service: tauri::State<'_, Mutex<AudioService>>,
92) -> Result<Vec<ExecutionTimingDto>, String> {
93 let service = audio_service
94 .lock()
95 .map_err(|_| "Failed to lock audio service".to_string())?;
96
97 let timings = AudioLatencyMeasurementService::measure_all_dsp_timings(&service, 2048);
98
99 for timing in &timings {
100 info!(
101 processor = timing.processor_name,
102 execution_us_per_sample = timing.execution_us_per_sample,
103 "DSP chain processor timing"
104 );
105 }
106
107 Ok(timings)
108}
109
110/// Returns the algorithmic (design-inherent) delay for every processor in the DSP chain.
111///
112/// Algorithmic latency is the sample delay an effect introduces by design — e.g. a
113/// look-ahead limiter adds a fixed number of samples regardless of CPU speed.
114///
115/// For the current chain (Gain → Tone Stack → Master Volume) all values are **zero**
116/// because no processor uses a delay line or look-ahead buffer. This command still
117/// exists to provide the correct data shape for the developer UI and to remain correct
118/// when future processors with non-zero delay are added.
119///
120/// Results are both logged at `info` level and returned to the frontend.
121///
122/// # Returns
123///
124/// `Ok(latency)` — a vector of exactly three [`AlgorithmicLatencyDto`] entries:
125/// Gain → Tone Stack → Master Volume, each with `latency_samples = 0`.
126///
127/// # Errors
128///
129/// Returns `Err` if the [`AudioService`] mutex cannot be locked.
130///
131/// [`AlgorithmicLatencyDto`]: crate::domain::dto::algorithmic_latency_dto::AlgorithmicLatencyDto
132/// [`AudioService`]: crate::services::audio_service::AudioService
133#[tauri::command]
134pub fn measure_all_dsp_algorithmic_latency(
135 audio_service: tauri::State<'_, Mutex<AudioService>>,
136) -> Result<Vec<AlgorithmicLatencyDto>, String> {
137 let service = audio_service
138 .lock()
139 .map_err(|_| "Failed to lock audio service".to_string())?;
140
141 let latency = AudioLatencyMeasurementService::measure_all_dsp_algorithmic_latency(&service);
142
143 for item in &latency {
144 info!(
145 processor = item.processor_name,
146 latency_samples = item.latency_samples,
147 latency_ms = item.latency_ms,
148 "DSP chain processor algorithmic latency"
149 );
150 }
151
152 Ok(latency)
153}
154
155/// Estimates the I/O buffer latency from the current CPAL stream configuration.
156///
157/// Reads the configured frame count for both the input and output streams and converts
158/// them to milliseconds using `(frames / sample_rate) × 1000`. If either stream uses
159/// [`BufferSize::Default`], a fallback of 256 frames is used.
160///
161/// The result is logged at `info` level and returned to the frontend.
162///
163/// # Returns
164///
165/// `Ok(latency)` — a [`BufferLatencyDto`] with `input_buffer_latency_ms`,
166/// `output_buffer_latency_ms`, and `total_buffer_latency_ms`.
167///
168/// # Errors
169///
170/// Returns `Err` if the [`AudioService`] mutex cannot be locked.
171///
172/// [`BufferSize::Default`]: cpal::BufferSize::Default
173/// [`BufferLatencyDto`]: crate::domain::dto::buffer_latency_dto::BufferLatencyDto
174/// [`AudioService`]: crate::services::audio_service::AudioService
175#[tauri::command]
176pub fn measure_buffer_latency(
177 audio_service: tauri::State<'_, Mutex<AudioService>>,
178) -> Result<BufferLatencyDto, String> {
179 let service = audio_service
180 .lock()
181 .map_err(|_| "Failed to lock audio service".to_string())?;
182
183 let latency = AudioLatencyMeasurementService::measure_buffer_latency(&service);
184
185 info!(
186 input_buffer_latency_ms = latency.input_buffer_latency_ms,
187 output_buffer_latency_ms = latency.output_buffer_latency_ms,
188 total_buffer_latency_ms = latency.total_buffer_latency_ms,
189 "I/O buffer latency"
190 );
191
192 Ok(latency)
193}
194
195/// Measures true end-to-end round-trip latency using dedicated CPAL streams.
196///
197/// This is the only latency command that performs a **real hardware measurement** rather
198/// than an analytical estimate. The procedure is:
199///
200/// 1. The [`AudioService`] mutex is locked just long enough to clone the handler arc,
201/// then **released** so the main loopback and UI remain unblocked.
202/// 2. A dedicated OS thread is spawned that calls
203/// [`AudioLatencyMeasurementService::measure_round_trip_latency`], which in turn
204/// opens its own CPAL input/output streams, runs calibration and impulse detection,
205/// and returns the averaged result.
206/// 3. The command blocks until the thread finishes (typically 3–15 s depending on
207/// warmup and timeout settings), then logs and returns the result.
208///
209/// # Physical requirement
210///
211/// The audio interface output must be physically (or virtually) connected back to its
212/// input. Without this loopback the impulse can never be detected and the measurement
213/// will time out.
214///
215/// # Returns
216///
217/// `Ok(result)` — a [`RoundTripLatencyDto`] with:
218/// - `is_valid = true` and `latency_ms` set on success.
219/// - `is_valid = false` and a human-readable `error` on failure (e.g. timeout or no echo).
220///
221/// # Errors
222///
223/// Returns `Err` only if the [`AudioService`] mutex cannot be locked or the
224/// measurement thread panics unexpectedly.
225///
226/// [`AudioService`]: crate::services::audio_service::AudioService
227/// [`AudioLatencyMeasurementService::measure_round_trip_latency`]: crate::services::audio_latency_measurement_service::AudioLatencyMeasurementService::measure_round_trip_latency
228/// [`RoundTripLatencyDto`]: crate::domain::dto::round_trip_latency_dto::RoundTripLatencyDto
229#[tauri::command]
230pub fn measure_round_trip_latency(
231 audio_service: tauri::State<'_, Mutex<AudioService>>,
232) -> Result<RoundTripLatencyDto, String> {
233 let handler = {
234 let service = audio_service
235 .lock()
236 .map_err(|_| "Failed to lock audio service".to_string())?;
237 service.audio_handler().clone()
238 };
239
240 let latency = std::thread::spawn(move || {
241 AudioLatencyMeasurementService::measure_round_trip_latency(handler.as_ref())
242 })
243 .join()
244 .map_err(|_| "Round-trip measurement thread panicked".to_string())?;
245
246 if latency.is_valid {
247 info!(
248 round_trip_latency_ms = latency.latency_ms,
249 "Round-trip latency measurement"
250 );
251 } else {
252 info!(
253 error = latency.error.clone(),
254 "Round-trip latency measurement failed"
255 );
256 }
257
258 Ok(latency)
259}