Skip to main content

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}