oxideav_mpegts/clock.rs
1//! Program Clock Reference (PCR) recovery and continuity tracking per
2//! ISO/IEC 13818-1.
3//!
4//! Two parallel-but-related concerns live here:
5//!
6//! 1. **PCR recovery** ([`Pcr`], [`PcrTracker`]). §2.4.2.2 carries one
7//! 27 MHz system clock sample per program inside the adaptation
8//! field of TS packets whose PID matches a PMT's `PCR_PID`. A
9//! callable tracker watches each PCR sample on a given PID, detects
10//! **time-base discontinuities** (signalled by the
11//! `discontinuity_indicator` per §2.4.3.4 / §2.4.3.5 *or* by a PCR
12//! jump that exceeds a tolerance window), and surfaces per-sample
13//! **jitter** + an **instantaneous bitrate** estimate.
14//!
15//! 2. **Continuity counter** ([`ContinuityTracker`]). §2.4.3.2 carries
16//! a 4-bit per-PID counter that increments on every packet
17//! contributing a payload. Real-world streams drop packets, send
18//! spec-permitted duplicates, and (rarely) re-order packets — this
19//! tracker classifies every observed packet against the previous
20//! one on the same PID so a demuxer can react (zero a reassembler
21//! on a drop, skip a duplicate without emitting it twice).
22//!
23//! Neither tracker mutates input bytes; both are pure observers over
24//! the [`crate::TsPacket`] field set. The demuxer wires them in as the
25//! per-stream front door — the trackers themselves stay testable in
26//! isolation.
27//!
28//! ## PCR encoding (§2.4.2.2, equations 2-1/2-2/2-3)
29//!
30//! A PCR field is 48 bits in the adaptation field, split into:
31//!
32//! ```text
33//! program_clock_reference_base 33 bits (1/300 of 27 MHz = 90 kHz)
34//! reserved 6 bits (all 1s)
35//! program_clock_reference_extension 9 bits (1/27 MHz, range 0..299)
36//! ```
37//!
38//! The full 27 MHz sample value is
39//! `PCR = PCR_base × 300 + PCR_extension`. The base wraps every
40//! 2^33 / 90 000 ≈ 95443.72 s (~ 26.5 hours) and the full
41//! 27 MHz value wraps every (2^33 × 300) / 27_000_000 = same window.
42//!
43//! ## Jitter (§2.4.2.2 + §2.4.2.3 tolerance)
44//!
45//! Real-world re-multiplexers preserve PCR samples but disturb their
46//! arrival schedule; the spec states a ±500 ns tolerance on PCR values
47//! themselves, but PES + buffer scheduling tolerates much more on a
48//! per-sample arrival basis. We model **PCR jitter** as the signed
49//! difference, in 27 MHz ticks, between the observed PCR delta and the
50//! delta predicted by the instantaneous bitrate `R` computed from the
51//! previous pair of PCRs:
52//!
53//! ```text
54//! R = (PCR_{i-1} - PCR_{i-2}) / (byte_offset_{i-1} - byte_offset_{i-2})
55//! ΔPCR* = R × (byte_offset_i - byte_offset_{i-1})
56//! jitter_i = (PCR_i - PCR_{i-1}) - ΔPCR* [27 MHz ticks]
57//! ```
58//!
59//! The tracker keeps a small ring of recent jitter samples so callers
60//! can ask for peak / running-average / current values without
61//! reading the running state directly.
62//!
63//! ## Discontinuity surface
64//!
65//! Both trackers classify their observations into the same shape:
66//!
67//! * **`None` returned from `observe`** — input was continuous /
68//! predicted; no caller action needed.
69//! * **`Some(PcrEvent::Discontinuity { reason })`** — a time-base
70//! discontinuity has been crossed; downstream PTS/DTS values from
71//! *after* this PCR live in a new time-base relative to the prior
72//! ones. The reason carries enough info to log + decide whether to
73//! reset codec-side buffers.
74//! * **`Some(ContinuityEvent::{Dropped, Duplicate, OutOfOrder,
75//! Discontinuity})`** — what the CC tracker noticed. `Dropped` is
76//! the "wake up, packets were lost" signal; the others are
77//! spec-permitted.
78
79use crate::AdaptationField;
80
81// ----------------------------------------------------------------------
82// PCR value type
83// ----------------------------------------------------------------------
84
85/// 27 MHz Program Clock Reference sample. Stored as the composed
86/// `base × 300 + extension` value so callers can do plain integer
87/// arithmetic and ask for either component back when needed.
88///
89/// The full 27 MHz value uses 42 bits; we store it in a `u64` and
90/// expose modular subtraction so wraparound at `2^33 × 300` ticks
91/// (≈ 26.5 hours) is correct without callers having to think about
92/// it.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub struct Pcr {
95 /// Composed 27 MHz value: `base × 300 + extension`.
96 /// Range is `0 ..= (2^33 - 1) × 300 + 299`.
97 pub ticks_27mhz: u64,
98}
99
100/// Modulus of the composed 27 MHz PCR value — one full wrap.
101///
102/// Equal to `(1 << 33) × 300 = 2_576_980_377_600`. After the PCR
103/// value reaches this it wraps back to 0.
104pub const PCR_MODULUS_27MHZ: u64 = (1u64 << 33) * 300;
105
106/// Spec-defined PCR tolerance — ±500 ns at 27 MHz is ±13.5 ticks.
107/// We round to 14 ticks as the conservative integer bound.
108///
109/// Source: ISO/IEC 13818-1 §2.4.2.2 ("The PCR tolerance is ±500 ns").
110pub const PCR_TOLERANCE_27MHZ: u32 = 14;
111
112impl Pcr {
113 /// Build from the 33-bit base + 9-bit extension as they sit in
114 /// the adaptation field.
115 pub fn from_base_ext(base_90khz: u64, extension: u16) -> Self {
116 Self {
117 ticks_27mhz: (base_90khz & ((1u64 << 33) - 1)) * 300 + (extension as u64 % 300),
118 }
119 }
120
121 /// Build from an already-composed 27 MHz tick count.
122 pub fn from_ticks_27mhz(ticks: u64) -> Self {
123 Self {
124 ticks_27mhz: ticks % PCR_MODULUS_27MHZ,
125 }
126 }
127
128 /// 33-bit `program_clock_reference_base` half (90 kHz).
129 pub fn base_90khz(self) -> u64 {
130 self.ticks_27mhz / 300
131 }
132
133 /// 9-bit `program_clock_reference_extension` half (0..=299).
134 pub fn extension(self) -> u16 {
135 (self.ticks_27mhz % 300) as u16
136 }
137
138 /// Difference `self - other`, in 27 MHz ticks, on the modular
139 /// PCR ring (i.e. wraps correctly when `other` is greater than
140 /// `self` by less than half the modulus).
141 ///
142 /// Result is signed: positive when `self` is "after" `other`.
143 pub fn delta_ticks(self, other: Pcr) -> i64 {
144 let half = (PCR_MODULUS_27MHZ / 2) as i64;
145 let m = PCR_MODULUS_27MHZ as i64;
146 let raw = self.ticks_27mhz as i64 - other.ticks_27mhz as i64;
147 if raw > half {
148 raw - m
149 } else if raw < -half {
150 raw + m
151 } else {
152 raw
153 }
154 }
155}
156
157// ----------------------------------------------------------------------
158// PCR tracker
159// ----------------------------------------------------------------------
160
161/// Why a [`PcrTracker`] reported a discontinuity.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum DiscontinuityReason {
164 /// Stream signalled it explicitly — adaptation field carried
165 /// `discontinuity_indicator = 1` on a PCR-bearing packet.
166 /// §2.4.3.5: "In the packet in which the system time-base
167 /// discontinuity occurs."
168 Signalled,
169 /// No explicit signal but the observed PCR jump exceeded
170 /// [`PcrTracker::max_jitter_27mhz`] (≫ ±500 ns spec tolerance).
171 /// Some real-world muxers drop the indicator on a clean
172 /// re-mux, and the only evidence is the jump itself.
173 JumpExceedsTolerance {
174 /// Observed delta in 27 MHz ticks; signed.
175 observed_delta: i64,
176 /// Predicted delta from the running bitrate estimate.
177 predicted_delta: i64,
178 },
179}
180
181/// Outcome of feeding one packet to a [`PcrTracker`].
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum PcrEvent {
184 /// Packet carried a PCR and it was within the tolerance window —
185 /// `jitter_27mhz` is the signed residual against the running
186 /// bitrate's prediction (0 on the first two samples — see
187 /// [`PcrTracker`] for the formula).
188 Sample {
189 pcr: Pcr,
190 /// Signed jitter in 27 MHz ticks against the predicted
191 /// arrival time. 0 until the third PCR (the first lets us
192 /// remember a value, the second lets us bootstrap the rate,
193 /// the third is the first one we can compare).
194 jitter_27mhz: i64,
195 /// Instantaneous bitrate estimate (bits per second). 0 on
196 /// the first PCR; thereafter the rate computed from the
197 /// previous pair of PCRs and the byte offsets between them.
198 bitrate_bps: u64,
199 },
200 /// Either the adaptation field set `discontinuity_indicator = 1`
201 /// or the PCR jump exceeded `max_jitter_27mhz`. The internal
202 /// running rate has been reset; the next two PCR samples will
203 /// bootstrap a fresh estimate.
204 Discontinuity {
205 pcr: Pcr,
206 reason: DiscontinuityReason,
207 },
208}
209
210/// Per-PCR-PID PCR recovery + discontinuity detector.
211///
212/// Construct one per `PCR_PID` and call [`observe`] for every TS
213/// packet on that PID. Packets without a PCR are silently ignored
214/// (the tracker still reads them for the `discontinuity_indicator`
215/// flag, since §2.4.3.5 allows the indicator to be set in packets
216/// *prior* to the one carrying the new PCR).
217///
218/// The tracker keeps the last PCR + byte-offset to compute the
219/// instantaneous bitrate, and the *previous* pair so a fresh sample
220/// can have its jitter computed without a second sample arriving.
221#[derive(Debug, Clone)]
222pub struct PcrTracker {
223 /// Threshold (in 27 MHz ticks) beyond which an unsignalled jump
224 /// is reported as a discontinuity. Defaults to 100 ms × 27000 =
225 /// 2 700 000 ticks. The spec says ±500 ns is the *spec*
226 /// tolerance; real-world re-mux jitter is far higher (tens of
227 /// milliseconds) so we use a window large enough to ignore
228 /// schedule wobble but small enough to catch a clean time-base
229 /// reset.
230 pub max_jitter_27mhz: u32,
231 /// Last observed PCR + its byte offset in the stream (caller-
232 /// supplied via [`observe`]).
233 last: Option<(Pcr, u64)>,
234 /// Second-to-last observed PCR + byte offset; needed to
235 /// extrapolate the predicted arrival time for the current sample.
236 prev: Option<(Pcr, u64)>,
237 /// Sticky flag for the §2.4.3.5 "indicator may be set on multiple
238 /// packets up to and including the one with the new PCR" case.
239 /// When set, the next PCR observed is treated as the new
240 /// time-base anchor regardless of its delta.
241 pending_signalled_discontinuity: bool,
242}
243
244impl PcrTracker {
245 /// Default jitter window: 100 ms.
246 ///
247 /// At 27 MHz that's 2 700 000 ticks. Comfortably above the
248 /// ±500 ns spec PCR tolerance but tight enough that a clean
249 /// re-mux time-base reset (which jumps by seconds or hours)
250 /// fires the discontinuity path.
251 pub const DEFAULT_MAX_JITTER_27MHZ: u32 = 2_700_000;
252
253 /// Build a tracker with the default jitter window.
254 pub fn new() -> Self {
255 Self::with_jitter_window(Self::DEFAULT_MAX_JITTER_27MHZ)
256 }
257
258 /// Build a tracker with a custom jitter window (27 MHz ticks).
259 pub fn with_jitter_window(max_jitter_27mhz: u32) -> Self {
260 Self {
261 max_jitter_27mhz,
262 last: None,
263 prev: None,
264 pending_signalled_discontinuity: false,
265 }
266 }
267
268 /// Observe one packet's adaptation field.
269 ///
270 /// `byte_offset` is the position of this packet's first byte in
271 /// the contiguous TS stream. It only needs to be monotonic; the
272 /// tracker uses differences, not absolute values. Typical
273 /// callers pass `bytes_read - 188`.
274 ///
275 /// Returns `None` when the packet had no PCR and no pending
276 /// discontinuity to commit.
277 pub fn observe(&mut self, af: &AdaptationField<'_>, byte_offset: u64) -> Option<PcrEvent> {
278 // The discontinuity indicator may appear on packets *before*
279 // the one with the new PCR, per §2.4.3.5. Accumulate the
280 // flag and only act when the next PCR actually arrives.
281 if af.discontinuity_indicator {
282 self.pending_signalled_discontinuity = true;
283 }
284 let (base, ext) = match (af.pcr_base, af.pcr_extension) {
285 (Some(b), Some(e)) => (b, e),
286 _ => return None,
287 };
288 let pcr = Pcr::from_base_ext(base, ext);
289
290 // Signalled discontinuity wins over arithmetic — even a
291 // microscopic PCR jump is a reset if the encoder said so.
292 if self.pending_signalled_discontinuity {
293 self.pending_signalled_discontinuity = false;
294 self.last = Some((pcr, byte_offset));
295 self.prev = None;
296 return Some(PcrEvent::Discontinuity {
297 pcr,
298 reason: DiscontinuityReason::Signalled,
299 });
300 }
301
302 let event = match (self.prev, self.last) {
303 // First-ever PCR. Bootstrap. Jitter 0, bitrate 0.
304 (None, None) => PcrEvent::Sample {
305 pcr,
306 jitter_27mhz: 0,
307 bitrate_bps: 0,
308 },
309 // Second PCR. We can compute a bitrate but not a jitter
310 // residual yet (no prior rate to compare against).
311 (None, Some((last_pcr, last_off))) => {
312 let dpcr = pcr.delta_ticks(last_pcr);
313 let dbytes = byte_offset.saturating_sub(last_off);
314 let bitrate = compute_bitrate_bps(dpcr, dbytes);
315 PcrEvent::Sample {
316 pcr,
317 jitter_27mhz: 0,
318 bitrate_bps: bitrate,
319 }
320 }
321 // Third+ PCR. Predict the arrival delta from the prior
322 // pair's rate; compare with what we got.
323 (Some((prev_pcr, prev_off)), Some((last_pcr, last_off))) => {
324 let prev_dpcr = last_pcr.delta_ticks(prev_pcr);
325 let prev_dbytes = last_off.saturating_sub(prev_off).max(1);
326 let cur_dbytes = byte_offset.saturating_sub(last_off);
327 // Predicted delta = prev_dpcr × cur_dbytes / prev_dbytes.
328 // Use i128 to avoid overflow on long-running streams.
329 let predicted_delta =
330 ((prev_dpcr as i128) * (cur_dbytes as i128) / (prev_dbytes as i128)) as i64;
331 let observed_delta = pcr.delta_ticks(last_pcr);
332 let jitter = observed_delta - predicted_delta;
333 if jitter.unsigned_abs() > self.max_jitter_27mhz as u64 {
334 // Unsignalled jump. Reset and report.
335 self.last = Some((pcr, byte_offset));
336 self.prev = None;
337 return Some(PcrEvent::Discontinuity {
338 pcr,
339 reason: DiscontinuityReason::JumpExceedsTolerance {
340 observed_delta,
341 predicted_delta,
342 },
343 });
344 }
345 let bitrate = compute_bitrate_bps(observed_delta, cur_dbytes);
346 PcrEvent::Sample {
347 pcr,
348 jitter_27mhz: jitter,
349 bitrate_bps: bitrate,
350 }
351 }
352 // (Some, None) shouldn't happen — we always write `last`
353 // before `prev` advances. Treat as bootstrap.
354 (Some(_), None) => PcrEvent::Sample {
355 pcr,
356 jitter_27mhz: 0,
357 bitrate_bps: 0,
358 },
359 };
360
361 self.prev = self.last;
362 self.last = Some((pcr, byte_offset));
363 Some(event)
364 }
365
366 /// Most recent PCR sample, if any.
367 pub fn last_pcr(&self) -> Option<Pcr> {
368 self.last.map(|(p, _)| p)
369 }
370
371 /// Discard accumulated state — useful after the demuxer
372 /// independently detects an EOF + restart (e.g. an HLS
373 /// discontinuity boundary between two segments).
374 pub fn reset(&mut self) {
375 self.last = None;
376 self.prev = None;
377 self.pending_signalled_discontinuity = false;
378 }
379}
380
381impl Default for PcrTracker {
382 fn default() -> Self {
383 Self::new()
384 }
385}
386
387/// Bitrate (bits per second) from a `(delta_pcr_27mhz, delta_bytes)`
388/// pair. Returns 0 on degenerate inputs.
389fn compute_bitrate_bps(delta_pcr_27mhz: i64, delta_bytes: u64) -> u64 {
390 if delta_pcr_27mhz <= 0 || delta_bytes == 0 {
391 return 0;
392 }
393 // bits / second = (bytes × 8) × 27_000_000 / delta_pcr_27mhz.
394 // Use u128 to keep the numerator from overflowing on multi-MB
395 // gaps between PCRs.
396 let num = (delta_bytes as u128) * 8 * 27_000_000;
397 let den = delta_pcr_27mhz as u128;
398 (num / den) as u64
399}
400
401// ----------------------------------------------------------------------
402// Continuity counter tracker
403// ----------------------------------------------------------------------
404
405/// Outcome of feeding one packet to a [`ContinuityTracker`].
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum ContinuityEvent {
408 /// CC differs from previous by exactly 1 mod 16 (or this is the
409 /// first packet) — the expected case.
410 Continuous,
411 /// CC matched the previous packet's value. §2.4.3.3: "duplicate
412 /// packets may be sent as two, and only two, consecutive TS
413 /// packets". The current packet may be discarded if the previous
414 /// one was already emitted.
415 Duplicate,
416 /// CC did not increment (`adaptation_field_control` was `'00'`
417 /// or `'10'`). Spec-permitted; not a drop.
418 NoPayload,
419 /// CC jumped by more than 1 — at least one packet was lost.
420 /// Reported delta is `(current - previous - 1) mod 16`, i.e.
421 /// the minimum number of packets we know are missing (the
422 /// counter is only 4 bits so larger losses are
423 /// indistinguishable).
424 Dropped { gap: u8 },
425 /// `discontinuity_indicator = 1` on this packet — §2.4.3.4 says
426 /// the CC may be discontinuous and we should not interpret the
427 /// gap as a drop.
428 Discontinuity,
429}
430
431/// Per-PID continuity-counter tracker per ISO/IEC 13818-1 §2.4.3.3.
432///
433/// We track only the last **payload-bearing** packet's CC; the spec
434/// rule "the continuity_counter shall not be incremented when the
435/// adaptation_field_control of the packet equals '00' or '10'" means
436/// payload-less packets carry whatever CC the most recent payload
437/// packet had, and the next payload packet's expected CC is one more
438/// than that.
439#[derive(Debug, Default, Clone)]
440pub struct ContinuityTracker {
441 /// 4-bit CC of the most recent payload-bearing packet, or `None`
442 /// before any has been seen (and after a `Discontinuity` reset).
443 last_payload_cc: Option<u8>,
444 /// True iff the last payload-bearing packet's CC was already used
445 /// in a duplicate (i.e. we've seen the original + one duplicate
446 /// already). §2.4.3.3 caps duplicates at *one* — a third identical
447 /// CC is a drop, not a second duplicate.
448 duplicate_already_seen: bool,
449}
450
451impl ContinuityTracker {
452 /// Build an empty tracker.
453 pub fn new() -> Self {
454 Self::default()
455 }
456
457 /// Observe one TS packet's CC + adaptation-field info.
458 ///
459 /// `cc` is the packet's `continuity_counter`,
460 /// `has_payload` is true when `adaptation_field_control` has the
461 /// payload bit (`0b01` or `0b11`), and
462 /// `discontinuity_indicator` is the adaptation field's flag
463 /// (false when there is no adaptation field).
464 pub fn observe(
465 &mut self,
466 cc: u8,
467 has_payload: bool,
468 discontinuity_indicator: bool,
469 ) -> ContinuityEvent {
470 let cc = cc & 0x0F;
471 if discontinuity_indicator {
472 // Indicator clears any prior state; treat this packet
473 // as the new anchor.
474 if has_payload {
475 self.last_payload_cc = Some(cc);
476 } else {
477 self.last_payload_cc = None;
478 }
479 self.duplicate_already_seen = false;
480 return ContinuityEvent::Discontinuity;
481 }
482 if !has_payload {
483 // §2.4.3.3: CC does not increment on these packets. We
484 // don't move the anchor.
485 return ContinuityEvent::NoPayload;
486 }
487 let prev = match self.last_payload_cc {
488 None => {
489 self.last_payload_cc = Some(cc);
490 self.duplicate_already_seen = false;
491 return ContinuityEvent::Continuous;
492 }
493 Some(p) => p,
494 };
495 let expected = (prev + 1) & 0x0F;
496 if cc == expected {
497 self.last_payload_cc = Some(cc);
498 self.duplicate_already_seen = false;
499 ContinuityEvent::Continuous
500 } else if cc == prev && !self.duplicate_already_seen {
501 // First duplicate after the original — spec-permitted.
502 self.duplicate_already_seen = true;
503 ContinuityEvent::Duplicate
504 } else {
505 // Any other CC, including a second duplicate, is a drop.
506 let gap = cc.wrapping_sub(expected) & 0x0F;
507 self.last_payload_cc = Some(cc);
508 self.duplicate_already_seen = false;
509 // gap is "current - expected" mod 16. A drop of N
510 // packets gives gap = N (with N >= 1 by construction
511 // here — equal would have hit the Continuous branch).
512 // We report `gap` as the minimum-known missing count.
513 ContinuityEvent::Dropped { gap: gap.max(1) }
514 }
515 }
516
517 /// Discard accumulated state.
518 pub fn reset(&mut self) {
519 self.last_payload_cc = None;
520 self.duplicate_already_seen = false;
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::AdaptationField;
528
529 fn af_with_pcr(base: u64, ext: u16, discontinuity: bool) -> AdaptationField<'static> {
530 AdaptationField {
531 length: 7,
532 discontinuity_indicator: discontinuity,
533 random_access_indicator: false,
534 elementary_stream_priority_indicator: false,
535 pcr_flag: true,
536 opcr_flag: false,
537 splicing_point_flag: false,
538 transport_private_data_flag: false,
539 adaptation_field_extension_flag: false,
540 pcr_base: Some(base),
541 pcr_extension: Some(ext),
542 opcr_base: None,
543 opcr_extension: None,
544 splice_countdown: None,
545 transport_private_data: None,
546 adaptation_field_extension: None,
547 raw: &[],
548 }
549 }
550
551 fn af_no_pcr(discontinuity: bool) -> AdaptationField<'static> {
552 AdaptationField {
553 length: 1,
554 discontinuity_indicator: discontinuity,
555 random_access_indicator: false,
556 elementary_stream_priority_indicator: false,
557 pcr_flag: false,
558 opcr_flag: false,
559 splicing_point_flag: false,
560 transport_private_data_flag: false,
561 adaptation_field_extension_flag: false,
562 pcr_base: None,
563 pcr_extension: None,
564 opcr_base: None,
565 opcr_extension: None,
566 splice_countdown: None,
567 transport_private_data: None,
568 adaptation_field_extension: None,
569 raw: &[],
570 }
571 }
572
573 #[test]
574 fn pcr_round_trips_base_ext() {
575 let p = Pcr::from_base_ext(0x1_2345_6789, 200);
576 assert_eq!(p.base_90khz(), 0x1_2345_6789);
577 assert_eq!(p.extension(), 200);
578 let q = Pcr::from_ticks_27mhz(p.ticks_27mhz);
579 assert_eq!(p, q);
580 }
581
582 #[test]
583 fn pcr_delta_handles_wraparound() {
584 // A PCR just past 0 minus a PCR just below the modulus should
585 // give a small positive delta (i.e. the new sample is just
586 // after the wrap).
587 let near_max = Pcr::from_ticks_27mhz(PCR_MODULUS_27MHZ - 1000);
588 let just_after = Pcr::from_ticks_27mhz(500);
589 // 500 - (-1000) = 1500 forward across the wrap.
590 assert_eq!(just_after.delta_ticks(near_max), 1500);
591 // Reverse delta should be the negative of that.
592 assert_eq!(near_max.delta_ticks(just_after), -1500);
593 }
594
595 #[test]
596 fn pcr_tracker_bootstraps_quietly() {
597 // First sample → 0 jitter, 0 bitrate.
598 // Second sample → 0 jitter, but bitrate computable.
599 // Third sample → real jitter relative to the prior pair's rate.
600 let mut t = PcrTracker::new();
601 // 1 Mbit/s = 1_000_000 bps = 125_000 bytes/s.
602 // At 27 MHz that's 27_000_000 / 125_000 = 216 ticks per byte.
603 let pcr1 = Pcr::from_ticks_27mhz(0);
604 let pcr2 = Pcr::from_ticks_27mhz(216 * 1880); // 10 TS packets later
605 let pcr3 = Pcr::from_ticks_27mhz(216 * 1880 * 2);
606 let af1 = af_with_pcr(pcr1.base_90khz(), pcr1.extension(), false);
607 let af2 = af_with_pcr(pcr2.base_90khz(), pcr2.extension(), false);
608 let af3 = af_with_pcr(pcr3.base_90khz(), pcr3.extension(), false);
609 let e1 = t.observe(&af1, 0).unwrap();
610 match e1 {
611 PcrEvent::Sample { jitter_27mhz, .. } => assert_eq!(jitter_27mhz, 0),
612 _ => panic!("expected sample"),
613 }
614 let e2 = t.observe(&af2, 1880).unwrap();
615 match e2 {
616 PcrEvent::Sample {
617 jitter_27mhz,
618 bitrate_bps,
619 ..
620 } => {
621 assert_eq!(jitter_27mhz, 0);
622 assert_eq!(bitrate_bps, 1_000_000);
623 }
624 _ => panic!("expected sample"),
625 }
626 let e3 = t.observe(&af3, 1880 * 2).unwrap();
627 match e3 {
628 PcrEvent::Sample {
629 jitter_27mhz,
630 bitrate_bps,
631 ..
632 } => {
633 assert_eq!(jitter_27mhz, 0);
634 assert_eq!(bitrate_bps, 1_000_000);
635 }
636 _ => panic!("expected sample"),
637 }
638 }
639
640 #[test]
641 fn pcr_tracker_detects_signalled_discontinuity() {
642 let mut t = PcrTracker::new();
643 let pcr1 = Pcr::from_ticks_27mhz(0);
644 let pcr2 = Pcr::from_ticks_27mhz(216 * 1880);
645 let pcr3 = Pcr::from_ticks_27mhz(216 * 1880 * 2);
646 // Signalled reset before the third sample, but the third
647 // sample itself is *exactly* where the rate would predict —
648 // the indicator wins regardless.
649 t.observe(&af_with_pcr(pcr1.base_90khz(), pcr1.extension(), false), 0)
650 .unwrap();
651 t.observe(
652 &af_with_pcr(pcr2.base_90khz(), pcr2.extension(), false),
653 1880,
654 )
655 .unwrap();
656 let e3 = t
657 .observe(
658 &af_with_pcr(pcr3.base_90khz(), pcr3.extension(), true),
659 1880 * 2,
660 )
661 .unwrap();
662 match e3 {
663 PcrEvent::Discontinuity { reason, .. } => {
664 assert_eq!(reason, DiscontinuityReason::Signalled);
665 }
666 _ => panic!("expected discontinuity, got {e3:?}"),
667 }
668 // After the discontinuity the tracker is bootstrapped from
669 // the new sample as the anchor.
670 assert_eq!(t.last_pcr(), Some(pcr3));
671 }
672
673 #[test]
674 fn pcr_tracker_indicator_on_packet_before_new_pcr() {
675 // §2.4.3.5: the indicator may be set on packets prior to the
676 // one carrying the new PCR. Verify the tracker carries the
677 // flag forward.
678 let mut t = PcrTracker::new();
679 let pcr1 = Pcr::from_ticks_27mhz(0);
680 let pcr2 = Pcr::from_ticks_27mhz(1000);
681 t.observe(&af_with_pcr(pcr1.base_90khz(), pcr1.extension(), false), 0)
682 .unwrap();
683 // Indicator-only packet, no PCR. Should *not* fire an event.
684 assert!(t.observe(&af_no_pcr(true), 188).is_none());
685 // Next PCR triggers the deferred discontinuity event.
686 let e2 = t
687 .observe(
688 &af_with_pcr(pcr2.base_90khz(), pcr2.extension(), false),
689 376,
690 )
691 .unwrap();
692 match e2 {
693 PcrEvent::Discontinuity { reason, .. } => {
694 assert_eq!(reason, DiscontinuityReason::Signalled);
695 }
696 _ => panic!("expected discontinuity"),
697 }
698 }
699
700 #[test]
701 fn pcr_tracker_detects_unsignalled_jump() {
702 // Rate is 1 Mbit/s (216 ticks/byte). The third sample lands
703 // a full second too far ahead (27 000 000 ticks late). The
704 // tracker should classify it as a jump even with the
705 // discontinuity_indicator off.
706 let mut t = PcrTracker::new();
707 let pcr1 = Pcr::from_ticks_27mhz(0);
708 let pcr2 = Pcr::from_ticks_27mhz(216 * 1880);
709 let bad = Pcr::from_ticks_27mhz(216 * 1880 * 2 + 27_000_000);
710 t.observe(&af_with_pcr(pcr1.base_90khz(), pcr1.extension(), false), 0)
711 .unwrap();
712 t.observe(
713 &af_with_pcr(pcr2.base_90khz(), pcr2.extension(), false),
714 1880,
715 )
716 .unwrap();
717 let e3 = t
718 .observe(
719 &af_with_pcr(bad.base_90khz(), bad.extension(), false),
720 1880 * 2,
721 )
722 .unwrap();
723 match e3 {
724 PcrEvent::Discontinuity {
725 reason: DiscontinuityReason::JumpExceedsTolerance { .. },
726 ..
727 } => {}
728 _ => panic!("expected jump-exceeds-tolerance, got {e3:?}"),
729 }
730 }
731
732 #[test]
733 fn continuity_tracker_classifies_typical_flow() {
734 let mut t = ContinuityTracker::new();
735 assert_eq!(t.observe(5, true, false), ContinuityEvent::Continuous);
736 assert_eq!(t.observe(6, true, false), ContinuityEvent::Continuous);
737 // Duplicate (same CC, payload-bearing).
738 assert_eq!(t.observe(6, true, false), ContinuityEvent::Duplicate);
739 // Skip 7, jump to 9 → one packet dropped.
740 assert_eq!(
741 t.observe(9, true, false),
742 ContinuityEvent::Dropped { gap: 2 }
743 );
744 }
745
746 #[test]
747 fn continuity_tracker_wraps_at_15() {
748 let mut t = ContinuityTracker::new();
749 t.observe(14, true, false);
750 assert_eq!(t.observe(15, true, false), ContinuityEvent::Continuous);
751 assert_eq!(t.observe(0, true, false), ContinuityEvent::Continuous);
752 assert_eq!(t.observe(1, true, false), ContinuityEvent::Continuous);
753 }
754
755 #[test]
756 fn continuity_tracker_no_payload_does_not_advance() {
757 let mut t = ContinuityTracker::new();
758 t.observe(3, true, false);
759 // Payload-less packet — CC stays at 3.
760 assert_eq!(t.observe(3, false, false), ContinuityEvent::NoPayload);
761 // Next payload packet should be 4, not 5 — the CC didn't move.
762 assert_eq!(t.observe(4, true, false), ContinuityEvent::Continuous);
763 }
764
765 #[test]
766 fn continuity_tracker_discontinuity_indicator_resets() {
767 let mut t = ContinuityTracker::new();
768 t.observe(3, true, false);
769 // Big CC jump but indicator is set — should be Discontinuity,
770 // not Dropped.
771 assert_eq!(t.observe(10, true, true), ContinuityEvent::Discontinuity);
772 // Subsequent packets pick up from 10.
773 assert_eq!(t.observe(11, true, false), ContinuityEvent::Continuous);
774 }
775}