1use crate::audio::AudioFormat;
25use crate::clock::{MediaDuration, MediaTime};
26use crate::gstreamer_audio::{self, GstreamerAudioCapture};
27use crate::gstreamer_video::{self, GstreamerVideoCapture};
28
29#[derive(Debug, thiserror::Error)]
31pub enum Error {
32 #[error(transparent)]
34 Audio(#[from] gstreamer_audio::Error),
35 #[error(transparent)]
37 Video(#[from] gstreamer_video::Error),
38}
39
40#[derive(Debug, Clone, Copy)]
42pub struct SyncConfig {
43 pub audio_format: AudioFormat,
45 pub audio_frequency_hz: f32,
47 pub video_width: u32,
49 pub video_height: u32,
51 pub video_framerate: u32,
53 pub audio_chunk_frames: u64,
55 pub duration: MediaDuration,
57}
58
59impl SyncConfig {
60 #[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, duration: MediaDuration::from_seconds(1.0),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy)]
78pub struct SyncReport {
79 pub audio_frames: u64,
81 pub video_frames: u64,
83 pub first_audio_pts: MediaTime,
85 pub first_video_pts: MediaTime,
87 pub last_audio_pts: MediaTime,
92 pub last_video_pts: MediaTime,
95 pub drift: MediaDuration,
98}
99
100impl SyncReport {
101 #[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
127pub 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 = 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 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
203fn 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}