rustriff_lib/services/round_trip_latency_session/session.rs
1//! [`RoundTripLatencySession`] — the blocking, self-contained measurement runner.
2//!
3//! This module owns the CPAL stream lifecycle for the round-trip measurement.
4//! It opens private streams, runs the warmup, drains stale samples, then drives
5//! [`RoundTripMeasurementState`] sample-by-sample until a terminal outcome is reached.
6
7use crate::infrastructure::audio_handler::{AudioHandler, AudioHandlerTrait};
8use crate::services::round_trip_latency_session::constants::IMPULSE_COUNT;
9use crate::services::round_trip_latency_session::measurement_state::{
10 RoundTripMeasurementState, RoundTripTickOutcome,
11};
12use cpal::BufferSize;
13use ringbuf::consumer::Consumer;
14use ringbuf::producer::Producer;
15use std::thread;
16use std::time::{Duration, Instant};
17
18/// Self-contained round-trip latency measurement session.
19///
20/// `RoundTripLatencySession` has no fields; it acts as a namespace for the [`run`] function.
21/// All state lives on the stack inside that call, making the session automatically torn down
22/// when it returns — there is nothing to clean up manually.
23///
24/// # Thread safety
25///
26/// [`run`] is a blocking call designed to execute on a dedicated thread. The caller
27/// (`measure_round_trip_latency` Tauri command) clones the handler reference, releases the
28/// `Mutex<AudioService>` lock, and then spawns a thread that calls this function. This
29/// means the main audio engine remains fully operational during the measurement.
30///
31/// [`run`]: RoundTripLatencySession::run
32pub struct RoundTripLatencySession;
33
34impl RoundTripLatencySession {
35 /// Runs a complete round-trip latency measurement and returns the average in milliseconds.
36 ///
37 /// # What this function does
38 ///
39 /// 1. Determines a safe ring-buffer size from the handler's configured buffer frames
40 /// (falling back to 256 if `BufferSize::Default` is in use), then multiplies by 4 to
41 /// give the streams room to breathe during warmup and calibration.
42 /// 2. Creates a dedicated input ring buffer (`i_producer` → `i_consumer`) and a dedicated
43 /// output ring buffer (`o_producer` → `o_consumer`), both completely separate from the
44 /// main loopback ring buffers.
45 /// 3. Opens a CPAL input stream that pushes captured samples into `i_producer` and a CPAL
46 /// output stream that drains processed samples from `o_consumer`, then starts both.
47 /// 4. Sleeps for `stream_warmup` to let the OS audio scheduler and hardware settle.
48 /// 5. Drains all samples accumulated during warmup from `i_consumer` so that calibration
49 /// begins with fresh, stable ambient data.
50 /// 6. Enters the main sample-processing loop, feeding each incoming sample to
51 /// [`RoundTripMeasurementState::tick`] until a terminal outcome is reached or the
52 /// `overall_deadline` expires.
53 ///
54 /// The `overall_deadline` is set to `per_impulse_timeout × IMPULSE_COUNT + 2 s` to
55 /// account for calibration time and inter-impulse gaps while still guaranteeing the
56 /// function cannot block indefinitely.
57 ///
58 /// # Arguments
59 ///
60 /// * `handler` — Audio I/O factory. Used only to size ring buffers and build streams;
61 /// it is **not** the same handler instance that the main loopback uses concurrently.
62 /// * `per_impulse_timeout` — Maximum time to wait for a single echo after the impulse is
63 /// emitted. Recommended: 10 s for real hardware, shorter for unit tests.
64 /// * `stream_warmup` — How long to sleep after starting streams before beginning
65 /// calibration. Recommended: 1–2 s to allow ASIO/WASAPI buffers to stabilise.
66 ///
67 /// # Returns
68 ///
69 /// * `Ok(latency_ms)` — Averaged round-trip latency across all [`IMPULSE_COUNT`] cycles.
70 /// * `Err(message)` — Human-readable failure reason; either a timeout, an undetectable
71 /// echo (signal too quiet or output not routed to input), or an overall deadline breach.
72 pub fn run(
73 handler: &dyn AudioHandlerTrait,
74 per_impulse_timeout: Duration,
75 stream_warmup: Duration,
76 ) -> Result<f64, String> {
77 fn frames_or_default(buffer_size: BufferSize) -> usize {
78 match buffer_size {
79 BufferSize::Fixed(frames) => frames as usize,
80 BufferSize::Default => 256,
81 }
82 }
83
84 let configured_frames = frames_or_default(handler.input_config().buffer_size)
85 .max(frames_or_default(handler.output_config().buffer_size));
86 let ringbuffer_size = (configured_frames * 4).max(512);
87
88 let (i_producer, mut i_consumer) = AudioHandler::create_ringbuffer(ringbuffer_size);
89 let (mut o_producer, o_consumer) = AudioHandler::create_ringbuffer(ringbuffer_size);
90
91 let input_stream = handler.build_input_stream(i_producer);
92 let output_stream = handler.build_output_stream(o_consumer);
93 input_stream.play();
94 output_stream.play();
95
96 println!("[RT-MEASURE] Dedicated streams started. Warming up for {stream_warmup:?}...");
97 thread::sleep(stream_warmup);
98
99 let mut drained = 0usize;
100 while i_consumer.try_pop().is_some() {
101 drained += 1;
102 }
103 println!("[RT-MEASURE] Drained {drained} stale warmup samples. Starting calibration.");
104
105 let mut state = RoundTripMeasurementState::new();
106 let overall_deadline =
107 Instant::now() + per_impulse_timeout * IMPULSE_COUNT as u32 + Duration::from_secs(2);
108
109 loop {
110 if Instant::now() >= overall_deadline {
111 return Err("Round-trip measurement timed out (no echo received).".to_string());
112 }
113
114 if let Some(sample) = i_consumer.try_pop() {
115 match state.tick(sample, &mut |v| o_producer.try_push(v).is_ok(), per_impulse_timeout) {
116 RoundTripTickOutcome::Complete(avg_ms) => return Ok(avg_ms),
117 RoundTripTickOutcome::TimedOut => {
118 return Err(format!(
119 "Echo not detected above threshold {:.4}. Ensure output is physically routed back into input.",
120 state.threshold
121 ))
122 }
123 RoundTripTickOutcome::Ongoing => {}
124 }
125 } else {
126 thread::yield_now();
127 }
128 }
129 }
130}