Skip to main content

media/
sync.rs

1//! Audio/video sync harness (M-MEDIA.7 / AUT-103).
2//!
3//! Combines [`GstreamerAudioCapture`] and [`GstreamerVideoCapture`]
4//! into one harness that reports per-stream timing and inter-stream
5//! drift. Synthetic `audiotestsrc` + `videotestsrc` sources keep
6//! the assertion deterministic — live capture (M-MEDIA.15 / .16) will
7//! reuse the same harness shape but with `autoaudiosrc` / `autovideosrc`.
8//!
9//! # What "drift" means here
10//!
11//! Each stream stamps its own PTS via [`MediaTime::from_sample`] /
12//! [`MediaTime::from_frame`]. For synthetic sources, both PTS values
13//! are derived from per-stream counters at construction-time rates, so
14//! the per-stream PTS *is* the timeline. We report:
15//!
16//! - `first_audio_pts` / `first_video_pts` (≈ 0 s for synthetic
17//!   sources).
18//! - `last_audio_pts` / `last_video_pts` (= sample_count / rate and
19//!   frame_count / fps respectively).
20//! - `drift` = `|last_audio_pts - last_video_pts|`. For synthetic
21//!   sources this is near-zero by construction; for live sources it
22//!   reports the underlying clock disagreement.
23
24use crate::audio::AudioFormat;
25use crate::clock::{MediaDuration, MediaTime};
26use crate::gstreamer_audio::{self, GstreamerAudioCapture};
27use crate::gstreamer_video::{self, GstreamerVideoCapture};
28
29/// Failure modes for the sync harness.
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32    /// Audio-side spawn / read error.
33    #[error(transparent)]
34    Audio(#[from] gstreamer_audio::Error),
35    /// Video-side spawn / read error.
36    #[error(transparent)]
37    Video(#[from] gstreamer_video::Error),
38}
39
40/// Tunable parameters for one harness run.
41#[derive(Debug, Clone, Copy)]
42pub struct SyncConfig {
43    /// Audio sample rate + channel count.
44    pub audio_format: AudioFormat,
45    /// Sine frequency emitted by the audio test source.
46    pub audio_frequency_hz: f32,
47    /// Video frame dimensions.
48    pub video_width: u32,
49    /// Video frame dimensions.
50    pub video_height: u32,
51    /// Video framerate (fps).
52    pub video_framerate: u32,
53    /// Per-chunk audio frame count (e.g. 4_800 = 100 ms at 48 kHz).
54    pub audio_chunk_frames: u64,
55    /// Total wall-clock duration to capture.
56    pub duration: MediaDuration,
57}
58
59impl SyncConfig {
60    /// Default deterministic harness: 48 kHz mono audio + 64×36 30 fps
61    /// video for 1 second.
62    #[must_use]
63    pub fn deterministic_1s() -> Self {
64        Self {
65            audio_format: AudioFormat::mono_f32(48_000),
66            audio_frequency_hz: 440.0,
67            video_width: 64,
68            video_height: 36,
69            video_framerate: 30,
70            audio_chunk_frames: 4_800, // 100 ms
71            duration: MediaDuration::from_seconds(1.0),
72        }
73    }
74}
75
76/// Result of a single [`run`] capture.
77#[derive(Debug, Clone, Copy)]
78pub struct SyncReport {
79    /// Cumulative audio frames captured.
80    pub audio_frames: u64,
81    /// Cumulative video frames captured.
82    pub video_frames: u64,
83    /// PTS of the first audio chunk's first sample.
84    pub first_audio_pts: MediaTime,
85    /// PTS of the first video frame.
86    pub first_video_pts: MediaTime,
87    /// End-of-window timestamp for the last audio chunk
88    /// (`chunk.pts + chunk.duration`). Reporting the chunk end (vs
89    /// start) keeps the drift calculation symmetric with video — see
90    /// the [`drift`](Self::drift) field doc.
91    pub last_audio_pts: MediaTime,
92    /// End-of-display timestamp for the last video frame
93    /// (`frame.pts + 1/fps`). Symmetric with `last_audio_pts`.
94    pub last_video_pts: MediaTime,
95    /// `|last_audio_pts - last_video_pts|`. For synthetic test sources
96    /// this is near-zero by construction.
97    pub drift: MediaDuration,
98}
99
100impl SyncReport {
101    /// True when the inter-stream drift is within `tolerance`.
102    #[must_use]
103    pub fn drift_within(&self, tolerance: MediaDuration) -> bool {
104        self.drift.as_nanos().abs() <= tolerance.as_nanos().abs()
105    }
106}
107
108impl std::fmt::Display for SyncReport {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(
111            f,
112            "SyncReport(audio_frames={af} video_frames={vf} \
113             first_audio={fa:.6}s first_video={fv:.6}s \
114             last_audio={la:.6}s last_video={lv:.6}s \
115             drift={d:.6}s)",
116            af = self.audio_frames,
117            vf = self.video_frames,
118            fa = self.first_audio_pts.as_seconds(),
119            fv = self.first_video_pts.as_seconds(),
120            la = self.last_audio_pts.as_seconds(),
121            lv = self.last_video_pts.as_seconds(),
122            d = self.drift.as_seconds(),
123        )
124    }
125}
126
127/// Run one capture cycle. Spawns both audio + video sources, pulls
128/// audio chunks at `audio_chunk_frames` granularity and one video
129/// frame per video tick until `duration` is covered. Returns the
130/// summary [`SyncReport`].
131///
132/// # Errors
133///
134/// - [`Error::Audio`] / [`Error::Video`] on spawn or read failures.
135pub fn run(config: SyncConfig) -> Result<SyncReport, Error> {
136    let mut audio =
137        GstreamerAudioCapture::test_source(config.audio_format, config.audio_frequency_hz)?;
138    let mut video = GstreamerVideoCapture::test_source(
139        config.video_width,
140        config.video_height,
141        config.video_framerate,
142    )?;
143
144    let duration_seconds = config.duration.as_seconds();
145    let target_audio_frames =
146        seconds_to_count(duration_seconds, f64::from(config.audio_format.sample_rate));
147    let target_video_frames = seconds_to_count(duration_seconds, f64::from(config.video_framerate));
148
149    let mut first_audio_pts: Option<MediaTime> = None;
150    let mut last_audio_pts = MediaTime::ZERO;
151    let mut audio_frames_total: u64 = 0;
152    while audio_frames_total < target_audio_frames {
153        let want = config
154            .audio_chunk_frames
155            .min(target_audio_frames - audio_frames_total);
156        let chunk = audio.next_chunk(want)?;
157        if first_audio_pts.is_none() {
158            first_audio_pts = Some(chunk.pts());
159        }
160        // last_audio_pts tracks the END of the last delivered chunk
161        // (chunk start + chunk duration). Without this, comparing a
162        // chunk-start audio PTS against a single-frame-start video PTS
163        // under-counts audio by (chunk_duration − frame_duration);
164        // for 100 ms audio chunks vs 33 ms video frames that's 67 ms of
165        // architectural drift — masking the real sync behavior.
166        last_audio_pts = chunk.pts() + chunk.duration();
167        audio_frames_total += want;
168    }
169
170    let mut first_video_pts: Option<MediaTime> = None;
171    let mut last_video_pts = MediaTime::ZERO;
172    let mut video_frames_total: u64 = 0;
173    let frame_period = MediaDuration::from_seconds(1.0 / f64::from(config.video_framerate));
174    while video_frames_total < target_video_frames {
175        let frame = video.next_frame()?;
176        let pts = MediaTime::from_seconds(frame.pts_seconds);
177        if first_video_pts.is_none() {
178            first_video_pts = Some(pts);
179        }
180        // Symmetric with audio: report the END of the frame's display
181        // window (frame PTS + 1/fps).
182        last_video_pts = pts + frame_period;
183        video_frames_total += 1;
184    }
185
186    let drift = if last_audio_pts > last_video_pts {
187        last_audio_pts - last_video_pts
188    } else {
189        last_video_pts - last_audio_pts
190    };
191
192    Ok(SyncReport {
193        audio_frames: audio_frames_total,
194        video_frames: video_frames_total,
195        first_audio_pts: first_audio_pts.unwrap_or(MediaTime::ZERO),
196        first_video_pts: first_video_pts.unwrap_or(MediaTime::ZERO),
197        last_audio_pts,
198        last_video_pts,
199        drift,
200    })
201}
202
203/// Convert `seconds × rate` to a `u64` count, rounding to nearest.
204/// Both inputs are finite + non-negative in normal use; clamps to 0 on
205/// anything else.
206fn seconds_to_count(seconds: f64, rate: f64) -> u64 {
207    let n = (seconds * rate).round();
208    if !n.is_finite() || n < 0.0 {
209        return 0;
210    }
211    #[expect(
212        clippy::cast_possible_truncation,
213        clippy::cast_sign_loss,
214        reason = "seconds×rate values for a realistic capture stay well below 2^53 (~9e15) and are non-negative — pre-checked above"
215    )]
216    let v = n as u64;
217    v
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn report_drift_within_compares_absolute_nanos() {
226        let r = SyncReport {
227            audio_frames: 0,
228            video_frames: 0,
229            first_audio_pts: MediaTime::ZERO,
230            first_video_pts: MediaTime::ZERO,
231            last_audio_pts: MediaTime::ZERO,
232            last_video_pts: MediaTime::ZERO,
233            drift: MediaDuration::from_millis(5),
234        };
235        assert!(r.drift_within(MediaDuration::from_millis(10)));
236        assert!(!r.drift_within(MediaDuration::from_millis(1)));
237    }
238
239    #[test]
240    fn deterministic_1s_targets_match_expected_counts() {
241        let cfg = SyncConfig::deterministic_1s();
242        let target_a = seconds_to_count(
243            cfg.duration.as_seconds(),
244            f64::from(cfg.audio_format.sample_rate),
245        );
246        let target_v = seconds_to_count(cfg.duration.as_seconds(), f64::from(cfg.video_framerate));
247        assert_eq!(target_a, 48_000);
248        assert_eq!(target_v, 30);
249    }
250}