Skip to main content

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}