Skip to main content

ts_fix/ops/
pcr_restamp.rs

1//! PCR restamp operation.
2//!
3//! Recomputes the 42-bit Program Clock Reference, **per PCR PID**, using a
4//! timing model based on bitrate (robust repair) or interpolation between
5//! observed PCRs (best-effort smoothing). A transport stream may carry several
6//! programs, each with its own PCR PID; every PCR PID is restamped
7//! independently from its own anchor.
8//!
9//! # Discontinuity re-anchor (ITU-T H.222.0 §2.4.3.5)
10//!
11//! When a packet on a PCR_PID has adaptation-field
12//! [`discontinuity_indicator == 1`], it signals a **system-time-base
13//! discontinuity**: the next PCR on that PID samples a new clock. The restamp
14//! MUST NOT interpolate or smooth across this boundary — it resets that PID's
15//! anchor to the observed PCR, so the two segments are restamped independently
16//! from their own bases.
17//!
18//! # Genuine, unflagged discontinuities (#562)
19//!
20//! A break with **no** `discontinuity_indicator` is not a legal re-anchor
21//! point — it is the defect this operation exists to fix. `Interpolate` mode
22//! classifies every observed forward jump using
23//! [`super::pcr_conformance::PcrDiscDetector`] (which reuses
24//! `dvb_conformance::ConformanceMonitor`'s ETSI TR 101 290 §5.2.2 indicator
25//! 2.3b check — the 100 ms threshold is never re-derived here). A jump that
26//! trips 2.3b is **not** adopted as a sane observation; the PID's anchor is
27//! permanently "frozen" onto its pre-break rate from that point on (every
28//! later value is still on the same unflagged, jumped clock and would
29//! otherwise look like just another sane forward step one packet later), so
30//! the restamped PCR stays on one continuous timeline across — and past —
31//! the break. Only a legal flagged discontinuity un-freezes a PID (it
32//! replaces the anchor outright). `FromBitrate` mode already ignores
33//! observed values outside of flagged re-anchors, so it ships this same
34//! guarantee for free. See `ts-fix/src/ops/pcr_honor.rs` for the alternative
35//! **honor** repair, which flags such a break instead of rewriting it.
36//!
37//! # PCR 33-bit base wrap
38//!
39//! The PCR is a 42-bit field: 33-bit base (90 kHz) × 300 + 9-bit extension,
40//! so the full 27 MHz value wraps at `2^33 × 300` (PCR\_27MHZ\_MODULUS).
41//! The Interpolate mode handles a legal wrap (where the raw observed value
42//! appears to decrease) via modular forward-distance comparison. All computed
43//! values are reduced modulo `PCR_27MHZ_MODULUS` so they wrap at the PCR
44//! boundary rather than the u64 boundary.
45//!
46//! # Forward-compat note
47//!
48//! The PCR is set **in-place** via [`mpeg_ts::OwnedTsPacket::set_pcr`], which
49//! overwrites the existing 6-byte field without re-serialising the adaptation
50//! field (so length/stuffing are preserved). The shared
51//! [`crate::ops::TimingContext`] in `StreamModel` is the forward-compat carrier
52//! that v0.2's PTS/DTS-wrap op will reuse; PCR's per-PID anchors are local to
53//! this op.
54//!
55//! # SCTE-35 splice PTS is intentionally NOT adjusted (#417)
56//!
57//! A SCTE-35 `splice_time.pts_time` (and `pts_adjustment`) is a **PTS** on the
58//! program's 90 kHz **presentation** clock — the same timeline as the PES
59//! `PTS`/`DTS`. The **PCR** is the independent transport-layer clock reference.
60//! This op restamps only the PCR PID; it does **not** rewrite PES `PTS`/`DTS`, so
61//! the presentation timeline is unchanged and the cue stays aligned to the media.
62//! Shifting `splice_time.pts_time` by the PCR delta would therefore *desync* the
63//! cue from the (unchanged) PES PTS. So SCTE-35 cues are left byte-identical
64//! through a PCR restamp (see `tests/scte35_preserve.rs`). A splice-PTS
65//! adjustment becomes correct only once a PES-PTS-rebase op exists (the v0.2
66//! PTS/DTS-wrap op), at which point the cue would shift by the *same* PES-PTS delta.
67//!
68//! # Spec
69//!
70//! ISO/IEC 13818-1 (= ITU-T H.222.0) §2.4.3.5 (PCR semantics). PCR is
71//! **per-program**: the PMT names a `PCR_PID` per program (§2.4.4.9), and
72//! §2.7.2 requires the PCRs on "the PCR_PID **for each program**" — so a
73//! multi-program TS carries multiple PCR PIDs, which is why this op anchors
74//! and restamps each PCR PID independently.
75
76use alloc::collections::BTreeMap;
77
78use mpeg_ts::owned::OwnedTsPacket;
79use mpeg_ts::ts::{Pcr, TS_PACKET_SIZE, TsPacket};
80
81use crate::ops::pcr_conformance::PcrDiscDetector;
82use crate::ops::{Op, StreamModel};
83
84/// 27 MHz PCR wrap period: 33-bit base × 300 (ISO/IEC 13818-1 §2.4.3.5).
85const PCR_27MHZ_MODULUS: u64 = (1u64 << 33) * 300;
86
87/// PCR restamp mode.
88///
89/// `#[non_exhaustive]` — new modes (e.g. `from_external_clock`) may be added
90/// in future releases without a breaking change.
91#[non_exhaustive]
92#[derive(Debug, Clone)]
93pub enum PcrRestamp {
94    /// Interpolate PCRs from each PID's first anchor + observed inter-PCR rate
95    /// (best-effort smoothing of jitter; preserves observed values where sane).
96    Interpolate,
97    /// Recompute PCRs from a fixed bitrate (bits per second), per PID:
98    /// `PCR = anchor + (packets_since_anchor × 188 × 8 / bitrate) × 27_000_000`.
99    /// Robust against corrupted PCR values (ignores the observed value).
100    FromBitrate {
101        /// Bitrate in bits per second.
102        bps: u64,
103    },
104}
105
106impl PcrRestamp {
107    /// Interpolate PCRs from each PID's anchor + observed rate (jitter smoothing).
108    ///
109    /// # Example
110    /// ```
111    /// use ts_fix::PcrRestamp;
112    /// let cfg = PcrRestamp::interpolate();
113    /// ```
114    pub fn interpolate() -> Self {
115        Self::Interpolate
116    }
117
118    /// Recompute PCRs from a fixed bitrate (bits/second) — robust repair.
119    ///
120    /// # Example
121    /// ```
122    /// use ts_fix::PcrRestamp;
123    /// let cfg = PcrRestamp::from_bitrate(27_000_000);
124    /// ```
125    pub fn from_bitrate(bps: u64) -> Self {
126        Self::FromBitrate { bps }
127    }
128}
129
130/// Per-PID PCR anchor + running rate (in 27 MHz ticks per TS packet).
131#[derive(Clone, Copy)]
132struct Anchor {
133    /// 27 MHz value of the first PCR seen on this PID (preserved).
134    anchor_27mhz: u64,
135    /// `packet_count` at the anchor.
136    anchor_pkt: u64,
137    /// Last monotonic observation: (packet_count, 27 MHz) — for Interpolate rate.
138    last_obs_pkt: u64,
139    last_obs_27mhz: u64,
140    /// #562: once `Interpolate` mode has hit a genuine, unflagged
141    /// discontinuity on this PID, it is permanently "frozen" onto the
142    /// pre-break rate — every later observation is on the (still unflagged)
143    /// post-break clock and must never be re-adopted, or the output would
144    /// carry the exact same jump one packet later. Cleared only by a legal
145    /// flagged discontinuity, which replaces this `Anchor` outright.
146    frozen: bool,
147}
148
149/// PCR restamp operation — restamps every PCR PID independently.
150pub(crate) struct PcrRestampOp {
151    anchors: BTreeMap<u16, Anchor>,
152    mode: PcrRestamp,
153    /// TR 101 290 §5.2.2 indicator 2.3b classifier (#562) — tells
154    /// `Interpolate` mode whether an observed forward jump is a genuine,
155    /// unflagged discontinuity (must NOT be adopted as a "sane" observation)
156    /// rather than normal jitter or a legal 33-bit base wrap.
157    disc_detector: PcrDiscDetector,
158}
159
160impl PcrRestampOp {
161    pub(crate) fn new(mode: PcrRestamp) -> Self {
162        Self {
163            anchors: BTreeMap::new(),
164            mode,
165            disc_detector: PcrDiscDetector::new(),
166        }
167    }
168
169    /// 27 MHz ticks per 188-byte packet at `bps` (min 1).
170    fn ticks_per_packet(bps: u64) -> u64 {
171        let num = 188u64 * 8 * 27_000_000u64;
172        if bps == 0 || bps >= num {
173            1
174        } else {
175            (num / bps).max(1)
176        }
177    }
178
179    /// Read `(pid, pcr, discontinuity)` if this packet carries a PCR.
180    ///
181    /// `discontinuity` is `true` when `discontinuity_indicator == 1` in the
182    /// adaptation field (ITU-T H.222.0 §2.4.3.5).
183    fn read_pcr(packet: &[u8]) -> Option<(u16, Pcr, bool)> {
184        let pkt = TsPacket::parse(packet).ok()?;
185        let af = pkt.adaptation_field().and_then(|r| r.ok())?;
186        let pcr = af.pcr?;
187        Some((pkt.header.pid, pcr, af.discontinuity_indicator))
188    }
189}
190
191impl Op for PcrRestampOp {
192    fn process(&mut self, packet: &[u8], model: &mut StreamModel, out: &mut dyn FnMut(&[u8])) {
193        if packet.len() != TS_PACKET_SIZE {
194            out(packet);
195            return;
196        }
197        let Some((pid, current, discontinuity)) = Self::read_pcr(packet) else {
198            out(packet);
199            return;
200        };
201        let now = model.packet_count;
202
203        // TR 101 290 §5.2.2 indicator 2.3b classifier (#562): feed the
204        // ORIGINAL observed PCR through the shared conformance detector on
205        // every path so its per-PID PCR state tracks the true input
206        // timeline. Only `Interpolate` mode consults the verdict below — a
207        // `discontinuity`-flagged packet never raises 2.3b (it only fires
208        // when `discontinuity_indicator == 0`), so this call is a no-op for
209        // that branch.
210        let is_genuine_unflagged_break = self.disc_detector.feed(packet).is_some();
211
212        // System-time-base discontinuity (§2.4.3.5): re-anchor this PID to the
213        // current observed PCR. The discontinuity packet itself passes through
214        // unchanged (its discontinuity_indicator is in the AF flags byte, not
215        // touched by set_pcr).
216        if discontinuity {
217            let a = Anchor {
218                anchor_27mhz: current.as_27mhz(),
219                anchor_pkt: now,
220                last_obs_pkt: now,
221                last_obs_27mhz: current.as_27mhz(),
222                frozen: false,
223            };
224            self.anchors.insert(pid, a);
225            model.timing.has_anchor = true;
226            model.timing.clock_27mhz = current.as_27mhz();
227            out(packet);
228            return;
229        }
230
231        // First PCR on this PID → anchor, preserve as-is.
232        let Some(anchor) = self.anchors.get_mut(&pid) else {
233            let a = Anchor {
234                anchor_27mhz: current.as_27mhz(),
235                anchor_pkt: now,
236                last_obs_pkt: now,
237                last_obs_27mhz: current.as_27mhz(),
238                frozen: false,
239            };
240            self.anchors.insert(pid, a);
241            // Mark the shared timing context as anchored (forward-compat for v0.2).
242            model.timing.has_anchor = true;
243            model.timing.clock_27mhz = current.as_27mhz();
244            out(packet);
245            return;
246        };
247
248        let new_27mhz = match &self.mode {
249            PcrRestamp::FromBitrate { bps } => {
250                let delta = now.saturating_sub(anchor.anchor_pkt);
251                anchor
252                    .anchor_27mhz
253                    .wrapping_add(Self::ticks_per_packet(*bps) * delta)
254                    % PCR_27MHZ_MODULUS
255            }
256            PcrRestamp::Interpolate => {
257                // Derive the rate from the last monotonic observation on this PID.
258                let obs = current.as_27mhz();
259                let pkt_delta = now.saturating_sub(anchor.last_obs_pkt);
260                // Wrap-aware forward-distance check.  On a 33-bit PCR base wrap
261                // the raw `obs` is smaller than `last_obs_27mhz`, but the forward
262                // distance modulo the PCR modulus is a small positive step.
263                let fwd = obs.wrapping_sub(anchor.last_obs_27mhz) % PCR_27MHZ_MODULUS;
264                // #562: a genuine, unflagged discontinuity (TR 101 290 §5.2.2
265                // indicator 2.3b) is a forward jump too, but it must NOT be
266                // adopted as a "sane" observation — that would carry the
267                // defect straight into the restamped output. Once one is seen
268                // on this PID, `frozen` stays set: every later observation is
269                // still on the (still unflagged) post-break clock relative to
270                // the pre-break anchor, so it would look like just another
271                // "sane forward jump" one packet later and re-introduce the
272                // exact same break. Freezing keeps the PID on the pre-break
273                // rate indefinitely — one continuous timeline across (and
274                // past) the break — until a legal flagged discontinuity
275                // replaces the anchor outright.
276                if is_genuine_unflagged_break {
277                    anchor.frozen = true;
278                }
279                if !anchor.frozen && fwd > 0 && fwd < PCR_27MHZ_MODULUS / 2 && pkt_delta > 0 {
280                    // Sane forward observation (possibly across a wrap): trust it,
281                    // advance the anchor's observation window.
282                    anchor.last_obs_pkt = now;
283                    anchor.last_obs_27mhz = obs;
284                    obs
285                } else {
286                    // Frozen, or a non-monotonic/corrupt observation: recompute
287                    // from the anchor using the last known (pre-break) rate.
288                    let span_pkt = anchor.last_obs_pkt.saturating_sub(anchor.anchor_pkt).max(1);
289                    let span_ticks = anchor.last_obs_27mhz.saturating_sub(anchor.anchor_27mhz);
290                    let rate = (span_ticks / span_pkt).max(1);
291                    let delta = now.saturating_sub(anchor.anchor_pkt);
292                    anchor.anchor_27mhz.wrapping_add(rate * delta) % PCR_27MHZ_MODULUS
293                }
294            }
295        };
296
297        let mut buf = [0u8; TS_PACKET_SIZE];
298        buf.copy_from_slice(packet);
299        if OwnedTsPacket::set_pcr(&mut buf, Pcr::from_27mhz(new_27mhz)).is_ok() {
300            out(&buf);
301        } else {
302            out(packet);
303        }
304    }
305
306    fn flush(&mut self, _model: &mut StreamModel, _out: &mut dyn FnMut(&[u8])) {
307        // PCR restamp is stateless across packets beyond its per-PID anchors;
308        // nothing is buffered, so there is nothing to flush.
309    }
310}