Skip to content

Commit b9b9b78

Browse files
committed
harden playout_time synchronizer
1 parent 9db329f commit b9b9b78

1 file changed

Lines changed: 204 additions & 19 deletions

File tree

pulsebeam/src/rtp/sync.rs

Lines changed: 204 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,20 @@ use str0m::{
88
use tokio::time::Instant;
99

1010
const MIN_SR_UPDATE_INTERVAL: Duration = Duration::from_millis(200);
11+
const MAX_DRIFT_PPM: f64 = 50_000.0;
12+
const MAX_RTP_GAP_SECS: f64 = 10.0;
1113

1214
#[derive(Debug, Clone, Copy)]
1315
struct ClockReference {
1416
rtp_time: MediaTime,
1517
ntp_time: SystemTime,
18+
arrival_ts: Instant,
19+
}
20+
21+
impl ClockReference {
22+
fn server_time_at_anchor(&self, ntp_delta: Duration) -> Instant {
23+
self.arrival_ts + ntp_delta
24+
}
1625
}
1726

1827
#[derive(Debug)]
@@ -24,6 +33,9 @@ pub struct Synchronizer {
2433
base_rtp: Option<MediaTime>,
2534
base_server_time: Option<Instant>,
2635
pub estimated_clock_drift_ppm: f64,
36+
/// The server Instant that corresponds to a specific NTP time, representing
37+
/// the minimum observed propagation delay.
38+
ntp_anchor: Option<ClockReference>,
2739
}
2840

2941
impl Synchronizer {
@@ -36,6 +48,7 @@ impl Synchronizer {
3648
base_rtp: None,
3749
base_server_time: None,
3850
estimated_clock_drift_ppm: 0.0,
51+
ntp_anchor: None,
3952
}
4053
}
4154

@@ -45,18 +58,46 @@ impl Synchronizer {
4558
}
4659

4760
if self.base_rtp.is_none() {
48-
self.base_rtp = Some(packet.rtp_ts);
49-
self.base_server_time = Some(packet.arrival_ts);
61+
self.reset_baseline(packet.rtp_ts, packet.arrival_ts);
5062
}
5163

5264
let base_rtp = self.base_rtp.unwrap();
5365
let mut base_server_time = self.base_server_time.unwrap();
5466

5567
let rtp_delta = (packet.rtp_ts.numer() as i64).wrapping_sub(base_rtp.numer() as i64);
68+
let max_ticks = (MAX_RTP_GAP_SECS * self.clock_rate.get() as f64) as i64;
69+
70+
// Auto-reset on massive RTP leaps to prevent timeline corruption
71+
if rtp_delta.abs() > max_ticks {
72+
self.reset_baseline(packet.rtp_ts, packet.arrival_ts);
73+
packet.playout_time = packet.arrival_ts;
74+
return;
75+
}
76+
5677
let drift = self.estimated_clock_drift_ppm / 1_000_000.0;
57-
let drift_correction = 1.0 / (1.0 + drift);
58-
let seconds_delta = rtp_delta as f64 / self.clock_rate.get() as f64 * drift_correction;
78+
let drift_correction = 1.0 / (1.0 + drift).max(0.001);
79+
80+
// 1. If we have SR info, we can calculate the NTP time of this packet and use it for alignment.
81+
let mut ntp_expected_playout = None;
82+
if let Some(latest) = self.latest_sr {
83+
let rtp_delta = (packet.rtp_ts.numer() as i64).wrapping_sub(latest.rtp_time.numer() as i64);
84+
let ntp_delta_secs = rtp_delta as f64 / self.clock_rate.get() as f64 * drift_correction;
85+
let ntp_pkt = if ntp_delta_secs >= 0.0 {
86+
latest.ntp_time + Duration::from_secs_f64(ntp_delta_secs)
87+
} else {
88+
latest.ntp_time - Duration::from_secs_f64(-ntp_delta_secs)
89+
};
90+
91+
if let Some(anchor) = self.ntp_anchor {
92+
let ntp_delta = ntp_pkt
93+
.duration_since(anchor.ntp_time)
94+
.unwrap_or(Duration::ZERO);
95+
ntp_expected_playout = Some(anchor.server_time_at_anchor(ntp_delta));
96+
}
97+
}
5998

99+
// 2. Fallback/Standard path: use the local RTP-based baseline
100+
let seconds_delta = rtp_delta as f64 / self.clock_rate.get() as f64 * drift_correction;
60101
let mut expected_playout = if seconds_delta >= 0.0 {
61102
base_server_time + Duration::from_secs_f64(seconds_delta)
62103
} else {
@@ -65,7 +106,14 @@ impl Synchronizer {
65106
.unwrap_or(packet.arrival_ts)
66107
};
67108

68-
// Minimum envelope filter: safely absorb all network jitter without bounding box bounce
109+
// 3. Re-align: If the NTP-based estimate is significantly different, or if we just want
110+
// to sync multiple tracks, we should prioritize the NTP timeline.
111+
if let Some(ntp_playout) = ntp_expected_playout {
112+
// We use the NTP playout if it's available, as it's synchronized across all tracks.
113+
expected_playout = ntp_playout;
114+
}
115+
116+
// Minimum envelope filter: absorbs network jitter
69117
if packet.arrival_ts < expected_playout {
70118
let error = expected_playout.duration_since(packet.arrival_ts);
71119
if let Some(new_base) = base_server_time.checked_sub(error) {
@@ -78,6 +126,15 @@ impl Synchronizer {
78126
packet.playout_time = expected_playout;
79127
}
80128

129+
fn reset_baseline(&mut self, rtp_ts: MediaTime, arrival_ts: Instant) {
130+
self.base_rtp = Some(rtp_ts);
131+
self.base_server_time = Some(arrival_ts);
132+
self.first_sr = None;
133+
self.latest_sr = None;
134+
self.ntp_anchor = None;
135+
self.estimated_clock_drift_ppm = 0.0;
136+
}
137+
81138
fn add_sender_report(&mut self, sr: SenderInfo, now: Instant) {
82139
if let Some(last_time) = self.last_sr_time
83140
&& now.duration_since(last_time) < MIN_SR_UPDATE_INTERVAL
@@ -88,6 +145,7 @@ impl Synchronizer {
88145
let current = ClockReference {
89146
rtp_time: sr.rtp_time,
90147
ntp_time: sr.ntp_time,
148+
arrival_ts: now,
91149
};
92150

93151
if let Some(last) = self.latest_sr {
@@ -97,7 +155,7 @@ impl Synchronizer {
97155
return;
98156
}
99157
} else {
100-
self.first_sr = Some(current); // Permanent lock on the first report
158+
self.first_sr = Some(current);
101159
}
102160

103161
self.latest_sr = Some(current);
@@ -107,6 +165,22 @@ impl Synchronizer {
107165
self.estimated_clock_drift_ppm = Self::compute_clock_drift(&first, &latest);
108166
histogram!("rtp_sync_clock_drift_ppm").record(self.estimated_clock_drift_ppm);
109167
}
168+
169+
// Update the NTP anchor with a minimum envelope filter
170+
if let Some(anchor) = self.ntp_anchor {
171+
let ntp_delta = current
172+
.ntp_time
173+
.duration_since(anchor.ntp_time)
174+
.unwrap_or(Duration::ZERO);
175+
let expected_server = anchor.arrival_ts + ntp_delta;
176+
if now < expected_server {
177+
// This SR arrived earlier than the previous anchor relative to NTP.
178+
// It represents a lower propagation delay.
179+
self.ntp_anchor = Some(current);
180+
}
181+
} else {
182+
self.ntp_anchor = Some(current);
183+
}
110184
}
111185

112186
fn compute_clock_drift(first: &ClockReference, current: &ClockReference) -> f64 {
@@ -125,9 +199,15 @@ impl Synchronizer {
125199
}
126200

127201
let expected_rtp_delta = sender_ntp_delta_secs * first.rtp_time.frequency().get() as f64;
202+
203+
if expected_rtp_delta <= 0.0 {
204+
return 0.0;
205+
}
206+
128207
let drift_ratio = (sender_rtp_delta as f64 - expected_rtp_delta) / expected_rtp_delta;
129208

130-
drift_ratio * 1_000_000.0
209+
// Clamp drift to physical boundaries (+/- 5%) to avoid infinity during pauses
210+
(drift_ratio * 1_000_000.0).clamp(-MAX_DRIFT_PPM, MAX_DRIFT_PPM)
131211
}
132212

133213
pub fn is_synchronized(&self) -> bool {
@@ -159,7 +239,6 @@ mod tests {
159239
let mut sync = Synchronizer::new(VIDEO_FREQUENCY);
160240
let base_time = Instant::now();
161241

162-
// 1. First packet establishes baseline (simulated with 100ms jitter delay)
163242
let p1_arrival = base_time + Duration::from_millis(100);
164243
let mut p1 = RtpPacket {
165244
rtp_ts: MediaTime::from_90khz(90_000),
@@ -169,29 +248,22 @@ mod tests {
169248
sync.process(&mut p1, None);
170249
assert_eq!(p1.playout_time, p1_arrival);
171250

172-
// 2. Second packet arrives exactly 1 second of media later, but with NO jitter.
173-
// This packet arrives 900ms after the first packet instead of 1000ms.
174251
let p2_arrival = base_time + Duration::from_secs(1);
175252
let mut p2 = RtpPacket {
176253
rtp_ts: MediaTime::from_90khz(180_000),
177254
arrival_ts: p2_arrival,
178255
..Default::default()
179256
};
180257
sync.process(&mut p2, None);
181-
182-
// The baseline should shift backward seamlessly! The playout matches arrival.
183258
assert_eq!(p2.playout_time, p2_arrival);
184259

185-
// 3. Third packet arrives 2 seconds of media later, but with 50ms of jitter again.
186260
let p3_arrival = base_time + Duration::from_secs(2) + Duration::from_millis(50);
187261
let mut p3 = RtpPacket {
188262
rtp_ts: MediaTime::from_90khz(270_000),
189263
arrival_ts: p3_arrival,
190264
..Default::default()
191265
};
192266
sync.process(&mut p3, None);
193-
194-
// The expected playout time MUST strip the jitter and map exactly to the updated server timeline.
195267
let expected_p3_playout = base_time + Duration::from_secs(2);
196268
assert_eq!(p3.playout_time, expected_p3_playout);
197269
}
@@ -243,7 +315,6 @@ mod tests {
243315
assert_eq!(sync_perfect.estimated_clock_drift_ppm.round() as i64, 0);
244316
assert_eq!(sync_drifting.estimated_clock_drift_ppm.round() as i64, 1000);
245317

246-
// Verify alignment 10 seconds in.
247318
let event_time = base_time + Duration::from_secs(10);
248319

249320
let mut p_perf = RtpPacket {
@@ -260,7 +331,6 @@ mod tests {
260331
};
261332
sync_drifting.process(&mut p_drift, None);
262333

263-
// The absolute offset perfectly aligns both playout clocks to the exact same Instant base
264334
let diff = if p_perf.playout_time > p_drift.playout_time {
265335
p_perf.playout_time - p_drift.playout_time
266336
} else {
@@ -308,7 +378,6 @@ mod tests {
308378
);
309379
}
310380

311-
// Test 10s into the future
312381
let event_time = base_time + Duration::from_secs(10);
313382

314383
let mut p_perf = RtpPacket {
@@ -325,12 +394,128 @@ mod tests {
325394
};
326395
sync_drifting.process(&mut p_drift, None);
327396

328-
// Even with fully decoupled NTP uptime bases, the single shared server baseline aligns flawlessly.
329397
let diff = if p_perf.playout_time > p_drift.playout_time {
330398
p_perf.playout_time - p_drift.playout_time
331399
} else {
332400
p_drift.playout_time - p_perf.playout_time
333401
};
334402
assert!(diff < Duration::from_micros(1));
335403
}
404+
405+
#[test]
406+
fn test_massive_rtp_gap_resets_baseline() {
407+
let mut sync = Synchronizer::new(VIDEO_FREQUENCY);
408+
let base_time = Instant::now();
409+
410+
// 1. Normal packet
411+
let p1_arrival = base_time;
412+
let mut p1 = RtpPacket {
413+
rtp_ts: MediaTime::from_90khz(90_000),
414+
arrival_ts: p1_arrival,
415+
..Default::default()
416+
};
417+
sync.process(&mut p1, None);
418+
419+
// 2. Massive gap (e.g. 15 seconds)
420+
let p2_arrival = base_time + Duration::from_secs(15);
421+
let mut p2 = RtpPacket {
422+
rtp_ts: MediaTime::from_90khz(90_000 + (15 * 90_000)),
423+
arrival_ts: p2_arrival,
424+
..Default::default()
425+
};
426+
sync.process(&mut p2, None);
427+
428+
// Expect the baseline to reset, mapping playout exactly to the new arrival
429+
assert_eq!(p2.playout_time, p2_arrival);
430+
assert_eq!(sync.base_rtp.unwrap(), p2.rtp_ts);
431+
assert_eq!(sync.estimated_clock_drift_ppm, 0.0);
432+
}
433+
434+
#[test]
435+
fn test_ntp_alignment_recovers_from_initial_delay() {
436+
let mut sync = Synchronizer::new(VIDEO_FREQUENCY);
437+
let base_time = Instant::now();
438+
let ntp_base = UNIX_EPOCH + Duration::from_secs(NTP_UNIX_OFFSET_SECS + 1000);
439+
440+
// 1. First packet arrives with 5s delay. Baseline pins to 5s.
441+
let mut p1 = RtpPacket {
442+
rtp_ts: MediaTime::from_90khz(0),
443+
arrival_ts: base_time + Duration::from_secs(5),
444+
..Default::default()
445+
};
446+
sync.process(&mut p1, None);
447+
assert_eq!(p1.playout_time - base_time, Duration::from_secs(5));
448+
449+
// 2. An SR arrives that reveals the true NTP.
450+
// Even if the packet carrying the SR is late, the anchor filter
451+
// will establish a mapping.
452+
let mut p2 = RtpPacket {
453+
rtp_ts: MediaTime::from_90khz(90_000), // 1s media later
454+
arrival_ts: base_time + Duration::from_secs(6), // Still 5s late
455+
..Default::default()
456+
};
457+
sync.process(
458+
&mut p2,
459+
Some(create_sr(MediaTime::from_90khz(0), ntp_base)),
460+
);
461+
// It's still late because we haven't seen a fast packet yet.
462+
assert_eq!(p2.playout_time - base_time, Duration::from_secs(6));
463+
464+
// 3. A fast packet arrives (only 100ms delay).
465+
// Sent at T=2s (RTP=180,000). Arrives at T=2.1s.
466+
let mut p3 = RtpPacket {
467+
rtp_ts: MediaTime::from_90khz(180_000),
468+
arrival_ts: base_time + Duration::from_secs(2) + Duration::from_millis(100),
469+
..Default::default()
470+
};
471+
sync.process(&mut p3, None);
472+
473+
// The playout MUST snap back to the low-latency timeline!
474+
assert_eq!(p3.playout_time - base_time, Duration::from_millis(2100));
475+
}
476+
477+
#[test]
478+
fn test_independent_streams_align_via_shared_ntp() {
479+
let base_time = Instant::now();
480+
let ntp_base = UNIX_EPOCH + Duration::from_secs(NTP_UNIX_OFFSET_SECS + 1000);
481+
482+
let mut sync1 = Synchronizer::new(VIDEO_FREQUENCY);
483+
let mut sync2 = Synchronizer::new(VIDEO_FREQUENCY);
484+
485+
// Stream 1: Low delay (100ms)
486+
let mut p1 = RtpPacket {
487+
rtp_ts: MediaTime::from_90khz(0),
488+
arrival_ts: base_time + Duration::from_millis(100),
489+
..Default::default()
490+
};
491+
sync1.process(&mut p1, Some(create_sr(MediaTime::from_90khz(0), ntp_base)));
492+
493+
// Stream 2: High delay (5s)
494+
let mut p2 = RtpPacket {
495+
rtp_ts: MediaTime::from_90khz(0),
496+
arrival_ts: base_time + Duration::from_secs(5),
497+
..Default::default()
498+
};
499+
sync2.process(&mut p2, Some(create_sr(MediaTime::from_90khz(0), ntp_base)));
500+
501+
// Now both have SRs. Stream 2 is still "late" because it hasn't seen a fast packet.
502+
// But if we send a packet that arrives with low delay for Stream 2:
503+
let mut p3 = RtpPacket {
504+
rtp_ts: MediaTime::from_90khz(90_000),
505+
arrival_ts: base_time + Duration::from_secs(1) + Duration::from_millis(100),
506+
..Default::default()
507+
};
508+
sync2.process(&mut p3, None);
509+
510+
// And Stream 1 sends its own packet at the same media time:
511+
let mut p4 = RtpPacket {
512+
rtp_ts: MediaTime::from_90khz(90_000),
513+
arrival_ts: base_time + Duration::from_secs(1) + Duration::from_millis(100),
514+
..Default::default()
515+
};
516+
sync1.process(&mut p4, None);
517+
518+
// They must be perfectly aligned now!
519+
assert_eq!(p3.playout_time, p4.playout_time);
520+
}
336521
}

0 commit comments

Comments
 (0)