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//! # PCR 33-bit base wrap
19//!
20//! The PCR is a 42-bit field: 33-bit base (90 kHz) × 300 + 9-bit extension,
21//! so the full 27 MHz value wraps at `2^33 × 300` (PCR\_27MHZ\_MODULUS).
22//! The Interpolate mode handles a legal wrap (where the raw observed value
23//! appears to decrease) via modular forward-distance comparison. All computed
24//! values are reduced modulo `PCR_27MHZ_MODULUS` so they wrap at the PCR
25//! boundary rather than the u64 boundary.
26//!
27//! # Forward-compat note
28//!
29//! The PCR is set **in-place** via [`mpeg_ts::OwnedTsPacket::set_pcr`], which
30//! overwrites the existing 6-byte field without re-serialising the adaptation
31//! field (so length/stuffing are preserved). The shared
32//! [`crate::ops::TimingContext`] in `StreamModel` is the forward-compat carrier
33//! that v0.2's PTS/DTS-wrap op will reuse; PCR's per-PID anchors are local to
34//! this op.
35//!
36//! # SCTE-35 splice PTS is intentionally NOT adjusted (#417)
37//!
38//! A SCTE-35 `splice_time.pts_time` (and `pts_adjustment`) is a **PTS** on the
39//! program's 90 kHz **presentation** clock — the same timeline as the PES
40//! `PTS`/`DTS`. The **PCR** is the independent transport-layer clock reference.
41//! This op restamps only the PCR PID; it does **not** rewrite PES `PTS`/`DTS`, so
42//! the presentation timeline is unchanged and the cue stays aligned to the media.
43//! Shifting `splice_time.pts_time` by the PCR delta would therefore *desync* the
44//! cue from the (unchanged) PES PTS. So SCTE-35 cues are left byte-identical
45//! through a PCR restamp (see `tests/scte35_preserve.rs`). A splice-PTS
46//! adjustment becomes correct only once a PES-PTS-rebase op exists (the v0.2
47//! PTS/DTS-wrap op), at which point the cue would shift by the *same* PES-PTS delta.
48//!
49//! # Spec
50//!
51//! ISO/IEC 13818-1 (= ITU-T H.222.0) §2.4.3.5 (PCR semantics). PCR is
52//! **per-program**: the PMT names a `PCR_PID` per program (§2.4.4.9), and
53//! §2.7.2 requires the PCRs on "the PCR_PID **for each program**" — so a
54//! multi-program TS carries multiple PCR PIDs, which is why this op anchors
55//! and restamps each PCR PID independently.
56
57use alloc::collections::BTreeMap;
58
59use mpeg_ts::owned::OwnedTsPacket;
60use mpeg_ts::ts::{Pcr, TS_PACKET_SIZE, TsPacket};
61
62use crate::ops::{Op, StreamModel};
63
64/// 27 MHz PCR wrap period: 33-bit base × 300 (ISO/IEC 13818-1 §2.4.3.5).
65const PCR_27MHZ_MODULUS: u64 = (1u64 << 33) * 300;
66
67/// PCR restamp mode.
68///
69/// `#[non_exhaustive]` — new modes (e.g. `from_external_clock`) may be added
70/// in future releases without a breaking change.
71#[non_exhaustive]
72#[derive(Debug, Clone)]
73pub enum PcrRestamp {
74 /// Interpolate PCRs from each PID's first anchor + observed inter-PCR rate
75 /// (best-effort smoothing of jitter; preserves observed values where sane).
76 Interpolate,
77 /// Recompute PCRs from a fixed bitrate (bits per second), per PID:
78 /// `PCR = anchor + (packets_since_anchor × 188 × 8 / bitrate) × 27_000_000`.
79 /// Robust against corrupted PCR values (ignores the observed value).
80 FromBitrate {
81 /// Bitrate in bits per second.
82 bps: u64,
83 },
84}
85
86impl PcrRestamp {
87 /// Interpolate PCRs from each PID's anchor + observed rate (jitter smoothing).
88 ///
89 /// # Example
90 /// ```
91 /// use ts_fix::PcrRestamp;
92 /// let cfg = PcrRestamp::interpolate();
93 /// ```
94 pub fn interpolate() -> Self {
95 Self::Interpolate
96 }
97
98 /// Recompute PCRs from a fixed bitrate (bits/second) — robust repair.
99 ///
100 /// # Example
101 /// ```
102 /// use ts_fix::PcrRestamp;
103 /// let cfg = PcrRestamp::from_bitrate(27_000_000);
104 /// ```
105 pub fn from_bitrate(bps: u64) -> Self {
106 Self::FromBitrate { bps }
107 }
108}
109
110/// Per-PID PCR anchor + running rate (in 27 MHz ticks per TS packet).
111#[derive(Clone, Copy)]
112struct Anchor {
113 /// 27 MHz value of the first PCR seen on this PID (preserved).
114 anchor_27mhz: u64,
115 /// `packet_count` at the anchor.
116 anchor_pkt: u64,
117 /// Last monotonic observation: (packet_count, 27 MHz) — for Interpolate rate.
118 last_obs_pkt: u64,
119 last_obs_27mhz: u64,
120}
121
122/// PCR restamp operation — restamps every PCR PID independently.
123pub(crate) struct PcrRestampOp {
124 anchors: BTreeMap<u16, Anchor>,
125 mode: PcrRestamp,
126}
127
128impl PcrRestampOp {
129 pub(crate) fn new(mode: PcrRestamp) -> Self {
130 Self {
131 anchors: BTreeMap::new(),
132 mode,
133 }
134 }
135
136 /// 27 MHz ticks per 188-byte packet at `bps` (min 1).
137 fn ticks_per_packet(bps: u64) -> u64 {
138 let num = 188u64 * 8 * 27_000_000u64;
139 if bps == 0 || bps >= num {
140 1
141 } else {
142 (num / bps).max(1)
143 }
144 }
145
146 /// Read `(pid, pcr, discontinuity)` if this packet carries a PCR.
147 ///
148 /// `discontinuity` is `true` when `discontinuity_indicator == 1` in the
149 /// adaptation field (ITU-T H.222.0 §2.4.3.5).
150 fn read_pcr(packet: &[u8]) -> Option<(u16, Pcr, bool)> {
151 let pkt = TsPacket::parse(packet).ok()?;
152 let af = pkt.adaptation_field().and_then(|r| r.ok())?;
153 let pcr = af.pcr?;
154 Some((pkt.header.pid, pcr, af.discontinuity_indicator))
155 }
156}
157
158impl Op for PcrRestampOp {
159 fn process(&mut self, packet: &[u8], model: &mut StreamModel, out: &mut dyn FnMut(&[u8])) {
160 if packet.len() != TS_PACKET_SIZE {
161 out(packet);
162 return;
163 }
164 let Some((pid, current, discontinuity)) = Self::read_pcr(packet) else {
165 out(packet);
166 return;
167 };
168 let now = model.packet_count;
169
170 // System-time-base discontinuity (§2.4.3.5): re-anchor this PID to the
171 // current observed PCR. The discontinuity packet itself passes through
172 // unchanged (its discontinuity_indicator is in the AF flags byte, not
173 // touched by set_pcr).
174 if discontinuity {
175 let a = Anchor {
176 anchor_27mhz: current.as_27mhz(),
177 anchor_pkt: now,
178 last_obs_pkt: now,
179 last_obs_27mhz: current.as_27mhz(),
180 };
181 self.anchors.insert(pid, a);
182 model.timing.has_anchor = true;
183 model.timing.clock_27mhz = current.as_27mhz();
184 out(packet);
185 return;
186 }
187
188 // First PCR on this PID → anchor, preserve as-is.
189 let Some(anchor) = self.anchors.get_mut(&pid) else {
190 let a = Anchor {
191 anchor_27mhz: current.as_27mhz(),
192 anchor_pkt: now,
193 last_obs_pkt: now,
194 last_obs_27mhz: current.as_27mhz(),
195 };
196 self.anchors.insert(pid, a);
197 // Mark the shared timing context as anchored (forward-compat for v0.2).
198 model.timing.has_anchor = true;
199 model.timing.clock_27mhz = current.as_27mhz();
200 out(packet);
201 return;
202 };
203
204 let new_27mhz = match &self.mode {
205 PcrRestamp::FromBitrate { bps } => {
206 let delta = now.saturating_sub(anchor.anchor_pkt);
207 anchor
208 .anchor_27mhz
209 .wrapping_add(Self::ticks_per_packet(*bps) * delta)
210 % PCR_27MHZ_MODULUS
211 }
212 PcrRestamp::Interpolate => {
213 // Derive the rate from the last monotonic observation on this PID.
214 let obs = current.as_27mhz();
215 let pkt_delta = now.saturating_sub(anchor.last_obs_pkt);
216 // Wrap-aware forward-distance check. On a 33-bit PCR base wrap
217 // the raw `obs` is smaller than `last_obs_27mhz`, but the forward
218 // distance modulo the PCR modulus is a small positive step.
219 let fwd = obs.wrapping_sub(anchor.last_obs_27mhz) % PCR_27MHZ_MODULUS;
220 if fwd > 0 && fwd < PCR_27MHZ_MODULUS / 2 && pkt_delta > 0 {
221 // Sane forward observation (possibly across a wrap): trust it,
222 // advance the anchor's observation window.
223 anchor.last_obs_pkt = now;
224 anchor.last_obs_27mhz = obs;
225 obs
226 } else {
227 // Non-monotonic / corrupt observation: recompute from the
228 // anchor using the last known rate.
229 let span_pkt = anchor.last_obs_pkt.saturating_sub(anchor.anchor_pkt).max(1);
230 let span_ticks = anchor.last_obs_27mhz.saturating_sub(anchor.anchor_27mhz);
231 let rate = (span_ticks / span_pkt).max(1);
232 let delta = now.saturating_sub(anchor.anchor_pkt);
233 anchor.anchor_27mhz.wrapping_add(rate * delta) % PCR_27MHZ_MODULUS
234 }
235 }
236 };
237
238 let mut buf = [0u8; TS_PACKET_SIZE];
239 buf.copy_from_slice(packet);
240 if OwnedTsPacket::set_pcr(&mut buf, Pcr::from_27mhz(new_27mhz)).is_ok() {
241 out(&buf);
242 } else {
243 out(packet);
244 }
245 }
246
247 fn flush(&mut self, _model: &mut StreamModel, _out: &mut dyn FnMut(&[u8])) {
248 // PCR restamp is stateless across packets beyond its per-PID anchors;
249 // nothing is buffered, so there is nothing to flush.
250 }
251}