Host-source stutter: both stutters fixed at the source, and frames finally play on the host's cadence #249

Merged
enricobuehler merged 10 commits from worktree-host-source-stutter-fixes into main 2026-08-15 13:13:08 +00:00
30 changed files with 4080 additions and 214 deletions
+45 -3
View File
@@ -851,6 +851,15 @@ fn decode_loop(
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
// WP-C1 — the drought half of concealment. The loop below already conceals a SEQ GAP, but only
// when a later packet arrives to reveal it; when the wire simply goes quiet — Wi-Fi power-save
// bunching, the shape this preset already runs deeper for — nothing arrives to reveal anything
// and the ring drains into an underrun and a de-prime whose re-prime is a longer artifact than
// the audio that was missing.
let mut drought = punktfunk_core::audio::DroughtConceal::new(
punktfunk_core::audio::JitterTuning::AAUDIO.plc_max_ms(),
);
let mut last_packet = std::time::Instant::now();
// A/V sync (audio latency overhaul). This thread is the only place holding all three
// ingredients at once: the packet's host capture `pts_ns`, the ring depth (via the sync cell)
@@ -894,10 +903,15 @@ fn decode_loop(
sync.set_target(av.desired_depth(depth));
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
}
last_packet = std::time::Instant::now();
// Anything the drought path already covered is audio the stream now has;
// concealing it a second time here would insert samples it never carried and push
// everything after them later.
let already = drought.packet();
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
for _ in 0..gaps.missing_before(pkt.seq) {
for _ in 0..gaps.missing_before(pkt.seq).saturating_sub(already) {
let plc = frame_samples * channels;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
@@ -958,13 +972,17 @@ fn decode_loop(
// the picture); 0 with sync off, or before it has a video reference.
// Logged next to the depth because a deep ring on a jittery link is
// correct and only the offset separates that from audio held late.
// `plc_ms` is concealment synthesized for packet droughts: a healthy
// `underruns` bought with a climbing `plc_ms` is a link in trouble,
// not a link that is fine.
log::info!(
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} peak={window_peak:.3}",
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} plc_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
(depth / ms.max(1)) as u64,
counters.target_ms.load(Ordering::Relaxed),
av.offset_ms(),
drought.total_ms(),
);
window_peak = 0.0;
}
@@ -972,7 +990,31 @@ fn decode_loop(
Err(e) => log::debug!("audio: opus decode: {e}"),
}
}
Err(PunktfunkError::NoFrame) => {} // timeout
Err(PunktfunkError::NoFrame) => {
// Nothing on the wire. If the ring is draining with it, conceal from the decoder's
// own state — the same libopus interpolation the loss path uses, bounded by this
// preset's de-prime fuse so a genuinely dead stream is not papered over. ONE frame
// per tick, not a burst: this arm fires every 5 ms, which is the rate the callback
// drains at, so concealment keeps pace with playout instead of racing ahead of a
// depth reading it has already invalidated. `frame_samples` is 0 until something
// has decoded — there is no state to extrapolate from before then.
let depth_ms = (sync.depth() / ms.max(1)) as u32;
if frame_samples > 0 && drought.conceal(last_packet.elapsed(), depth_ms) {
let plc = frame_samples * channels;
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
buf.clear();
buf.extend_from_slice(&pcm[..samples * channels]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => return DecodeExit::Shutdown,
}
}
sync.publish_plc_ms(drought.total_ms());
}
}
Err(_) => return DecodeExit::SessionClosed,
}
}
@@ -37,6 +37,7 @@ struct OutputReady {
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
@@ -51,11 +52,12 @@ enum DecodeEvent {
Au(Frame, u32),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamps).
OutputAvailable {
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
@@ -126,6 +128,12 @@ pub(super) fn run_async(
// decode stage ends when the frame actually became available — not after the
// channel hop + whatever work the loop coalesces in front of presenting it.
decoded_ns: now_realtime_ns(),
// Its monotonic twin, from the same instant. The stats are REALTIME (they
// fold the host's clock offset in), while the cadence loop and
// `releaseOutputBufferAtTime` are both CLOCK_MONOTONIC — and the loop is fed
// and read in one domain, never converted (`punktfunk_core::phase`: a
// constant offset between domains is what its offset estimator absorbs).
decoded_mono_ns: now_monotonic_ns(),
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
@@ -232,7 +240,7 @@ pub(super) fn run_async(
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
}
);
Some(Presenter::new(priority))
Some(Presenter::new(priority, mode.refresh_hz))
};
stats.set_presenter_active(presenter.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
@@ -299,6 +307,7 @@ pub(super) fn run_async(
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
// `present_ready` (present), keyed by the codec-echoed pts.
let mut gate = ReanchorGate::new(client.frames_dropped());
let mut last_arms = gate.arms();
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
@@ -378,6 +387,19 @@ pub(super) fn run_async(
&mut queued_stamps,
&mut gate,
);
// The cadence loop's re-anchor seam. A fresh arm means a loss was detected: the gate is
// about to freeze on the last good picture and the frames that reach the presenter on the
// far side come through a decoder that has just recovered, so the source→presentable delay
// the loop had measured is not the one it will see. Watched by ARM COUNT rather than at
// each `gate.arm` site because those are spread across the dispatcher, the feeder and this
// loop's own backstops, and the count catches every one of them — including the ones
// `feed_ready` raises for an abandoned partial AU.
if gate.arms() != last_arms {
last_arms = gate.arms();
if let Some(p) = presenter.as_mut() {
p.reset_cadence();
}
}
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
@@ -414,7 +436,7 @@ pub(super) fn run_async(
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
if let Some(t) = c.next_target(now_monotonic_ns()) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let leads_us: Vec<u64> = arrival_stamps
@@ -737,10 +759,12 @@ fn dispatch_event(
index,
pts_us,
decoded_ns,
decoded_mono_ns,
} => ready.push(OutputReady {
index,
pts_us,
decoded_ns,
decoded_mono_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Vsync => *vsync_tick = true,
@@ -998,7 +1022,7 @@ fn present_ready(
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns, o.decoded_mono_ns);
skipped += dropped;
*discarded += dropped;
} else {
+269 -31
View File
@@ -14,6 +14,12 @@
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
//! identical to the legacy path — and only the budget prediction uses the measured period.
//! * under the SMOOTHNESS intent only, a **due time** per frame from
//! [`punktfunk_core::phase::CadenceClock`]: the release targets the first timeline at or after
//! the instant the SOURCE's own timestamp says the frame is due, rather than the first one
//! after it happened to decode, so the host's cadence reaches glass instead of the network's
//! (`design/presenter-cadence-rework.md` D1). The latency intent holds no clock at all and
//! stays arrival-driven by construction.
//!
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
@@ -21,6 +27,7 @@
//! (off = the synchronous pre-overhaul loop, no presenter at all).
use ndk::media::media_codec::MediaCodec;
use punktfunk_core::phase::{CadenceClock, CadenceHealth, CadenceTuning};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
@@ -103,8 +110,9 @@ const FALLBACK_PERIOD_NS: i64 = 8_333_333;
pub(crate) enum PresentPriority {
/// Newest-wins, release the instant the budget opens. The default.
Latency,
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
/// added display latency per slot, which the metrics show rather than hide.
/// A small holding store (1..=3 frames) drained on each frame's DUE time from the source's own
/// timestamps: transport jitter absorbed in the cushion the cadence loop sizes from its own
/// measured residual, at a latency the metrics show rather than hide.
Smooth { buffer: usize },
}
@@ -129,6 +137,9 @@ struct HeldFrame {
pts_us: u64,
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
decoded_ns: i128,
/// When the source says this frame is due, monotonic — [`CadenceClock::due_ns`] folded at
/// submit. `None` under the latency intent, which has no clock.
due_ns: Option<i64>,
}
/// The one-in-flight glass budget.
@@ -287,20 +298,33 @@ fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
}
pub(super) struct Presenter {
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
/// 0 = newest-wins; 1..=3 = the holding store's capacity, a bound against a burst rather than
/// a pacing depth (design §4.3).
fifo_capacity: usize,
frames: VecDeque<HeldFrame>,
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
prerolled: bool,
/// The source-cadence loop, and the ONLY thing that makes this presenter anything but
/// arrival-driven. `None` under the latency intent — not "a clock we choose to ignore" but no
/// clock at all, so that path cannot drift into cadence targeting by accident (`design/
/// presenter-cadence-rework.md` §5; the test below is what holds it there).
cadence: Option<CadenceClock>,
/// The source's nominal frame interval — [`CadenceClock`]'s cushion ceiling, which is a design
/// invariant rather than a tunable (a cushion past one whole frame buys smoothness the source
/// cannot supply). From the negotiated stream rate, which cannot change without tearing this
/// loop down.
frame_interval_ns: i64,
inflight: Option<InFlight>,
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
/// A vsync arrived since the last release — the retry beat for a parked frame, and what the
/// empty-store readout below is counted against.
vsync_tick: bool,
// -- 1 Hz pf-present window, always on --
released: u64,
paced_drops: u64,
no_budget: u64,
forced: u64,
/// Vsync ticks that found the store empty. Under cadence targeting an empty store is the
/// ordinary state between a frame's decode and its due time, so this reads as supply depth
/// rather than as the underflow alarm it was under the retired per-slot drain — the alarm is
/// the loop's own late count.
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
@@ -321,7 +345,12 @@ pub(super) struct Presenter {
}
impl Presenter {
pub(super) fn new(priority: PresentPriority) -> Presenter {
/// `source_hz` is the NEGOTIATED STREAM rate, not the panel's — it sizes the cadence loop's
/// cushion ceiling, and the quantity that must not be exceeded is one source frame. 0 (an
/// unnegotiated rate) falls back to [`FALLBACK_PERIOD_NS`], the tighter of the two plausible
/// answers: a ceiling set too low costs smoothness, one set too high costs latency the user
/// never asked for.
pub(super) fn new(priority: PresentPriority, source_hz: u32) -> Presenter {
let pinned = latch_margin_ns();
let (margin_ns, margin_pinned) = match pinned {
Some(ns) => (ns, true),
@@ -336,13 +365,27 @@ impl Presenter {
" (adaptive — widens on latch misses)"
}
);
let frame_interval_ns = match source_hz {
0 => FALLBACK_PERIOD_NS,
hz => 1_000_000_000 / i64::from(hz),
};
// `snapping()`, because every release here goes through the frame-timeline grid: the
// snap-up carries roughly half a refresh of implicit slack, so the cushion is small.
let cadence = matches!(priority, PresentPriority::Smooth { .. }).then(|| {
log::info!(
"presenter: cadence clock on the source's timeline, frame interval {:.2}ms",
frame_interval_ns as f64 / 1e6
);
CadenceClock::new(CadenceTuning::snapping())
});
Presenter {
fifo_capacity: match priority {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
},
frames: VecDeque::new(),
prerolled: false,
cadence,
frame_interval_ns,
inflight: None,
vsync_tick: false,
released: 0,
@@ -359,8 +402,9 @@ impl Presenter {
}
}
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
/// FIFO's drain pace.
/// A vsync pulse from the clock thread's event — the retry tick for a frame parked on a closed
/// budget, and the beat the empty-store readout is counted against. No longer a drain pace:
/// under cadence targeting the due time is what releases a frame.
pub(super) fn on_vsync(&mut self) {
self.vsync_tick = true;
}
@@ -368,13 +412,21 @@ impl Presenter {
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
///
/// `decoded_mono_ns` is the monotonic twin of `decoded_ns`, stamped at the same instant on the
/// codec's callback thread: the cadence loop and `releaseOutputBufferAtTime` both live in
/// `CLOCK_MONOTONIC`, while the latency stats live in `CLOCK_REALTIME`, and the loop is fed and
/// read in ONE domain (`phase.rs`: a constant offset between domains is absorbed by the offset
/// estimator, so no conversion belongs anywhere in this path).
pub(super) fn submit(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_ns: i128,
decoded_mono_ns: i64,
) -> u64 {
let due_ns = self.due_at(pts_us, decoded_mono_ns);
let mut dropped = 0u64;
if self.fifo_capacity == 0 {
while let Some(stale) = self.frames.pop_front() {
@@ -386,6 +438,7 @@ impl Presenter {
index,
pts_us,
decoded_ns,
due_ns,
});
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
if let Some(stale) = self.frames.pop_front() {
@@ -397,6 +450,61 @@ impl Presenter {
dropped
}
/// When the source says this frame is due, or `None` under the latency intent.
///
/// `pts_us` IS the host's own stamp — it round-trips through the codec's presentation time, so
/// the source timeline needs no plumbing of its own. The µs the codec API quantises it to
/// costs ±0.5 µs of white noise on an 8.3 ms period, and that passes through to the due time
/// like any other variation in the source's cadence: the loop smooths the offset, never the
/// timestamps.
fn due_at(&mut self, pts_us: u64, decoded_mono_ns: i64) -> Option<i64> {
let interval_ns = self.frame_interval_ns;
self.cadence
.as_mut()
.map(|c| c.due_ns(pts_us.saturating_mul(1_000), decoded_mono_ns, interval_ns))
}
/// The earliest present a release may target: SurfaceFlinger's latch lead ahead of now, since
/// a present it cannot latch in time is not a target, and never before the frame's own due
/// time. That second half is the whole of the cadence change — `next_target(max(now, due))`
/// where it used to be `next_target(now)` (design §4.2).
fn not_before_ns(&self, now_mono_ns: i64, due_ns: Option<i64>) -> i64 {
let submit_floor_ns = now_mono_ns + self.margin_ns;
due_ns.map_or(submit_floor_ns, |due| due.max(submit_floor_ns))
}
/// Whether the frame at the head of the smoothing store may leave it yet.
///
/// The store's job under cadence targeting is to HOLD WHAT IS NOT DUE YET (design §4.3): the
/// due time paces, so capacity is a bound against a burst rather than the clock. A frame is
/// releasable once the grid point it aims at is the next one this pump could still submit for
/// — one `grid_period_ns` of reach past the submit margin. Sooner buys nothing, because the
/// release is timed either way and holding keeps the store's own eviction policy live over the
/// frame; later risks the loop's 5 ms housekeeping wake landing inside the submit lead, which
/// costs the frame a whole refresh.
fn head_is_releasable(&self, now_mono_ns: i64, grid_period_ns: i64) -> bool {
let reach_ns = now_mono_ns + self.margin_ns + grid_period_ns;
self.frames
.front()
.is_some_and(|f| f.due_ns.is_none_or(|due| due <= reach_ns))
}
/// Force the cadence loop to re-anchor on the next frame — the discontinuity hook the clock
/// asks its callers for. The Android seam is the re-anchor gate arming: a loss freezes the
/// gate, the decoder recovers behind it, and what reaches this store on the far side comes
/// through a pipeline whose delay is no longer the one the loop measured. Source-timestamp
/// regressions and half-second gaps the loop catches by itself. No-op under latency.
pub(super) fn reset_cadence(&mut self) {
if let Some(c) = self.cadence.as_mut() {
c.reset();
}
}
/// The cadence loop's health for the 1 Hz line, or `None` when there is no loop to read.
fn cadence_health(&self) -> Option<CadenceHealth> {
self.cadence.as_ref().map(CadenceClock::health)
}
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
/// `true` when a frame was released to glass this call.
@@ -423,28 +531,28 @@ impl Presenter {
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// The grid a release snaps to, for the store's due-time reach below: the panel's own
// period where it is known — the app's choreographer stream can be down-rated below it
// (see `VsyncShared::next_target`) — and the measured callback period otherwise.
let grid_period_ns = clock
.map(|c| c.panel_period_ns().max(c.period_ns()))
.filter(|&p| p > 0)
.unwrap_or(FALLBACK_PERIOD_NS);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
} else {
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
// the previous frame persists on glass, a repeat by omission, while headroom
// rebuilds). Everything is gated on the tick so an idle stream neither counts
// underflows nor churns the preroll flag 200×/s.
if !self.vsync_tick {
return false;
}
if !self.prerolled {
if self.frames.len() < self.fifo_capacity {
return false;
// The smoothing store releases on the DUE time, so there is no drain tick to gate on
// and no preroll beneath it. One-frame-per-slot was itself the defect: at 60 fps on a
// 120 Hz panel it drains at twice supply, the store empties, preroll re-arms, and the
// intervals become 1,3,1,3 where 2,2,2 is the correct answer — the smoothing mode
// juddering by construction at exactly the rate mismatch it exists to smooth (design
// §4.3, D3). Due times one source period apart snap to every second vblank instead.
if !self.head_is_releasable(now_mono_ns, grid_period_ns) {
if self.vsync_tick && self.frames.is_empty() {
self.dry += 1;
}
self.prerolled = true;
}
if self.frames.is_empty() {
self.prerolled = false;
self.dry += 1;
self.vsync_tick = false; // this tick's drain ran (and found nothing)
self.vsync_tick = false; // this tick's evaluation ran (and released nothing)
return false;
}
self.frames.pop_front()
@@ -463,8 +571,11 @@ impl Presenter {
}
return false;
}
// Release: timeline-timed when the clock has one, ASAP otherwise.
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
// Release: timeline-timed when the clock has one, ASAP otherwise. Under cadence targeting
// the floor is the frame's due time rather than this instant — the source's grid rather
// than the network's.
let target =
clock.and_then(|c| c.next_target(self.not_before_ns(now_mono_ns, frame.due_ns)));
let released = match target {
Some(t) => codec
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
@@ -549,6 +660,12 @@ impl Presenter {
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
///
/// Under the smoothness intent it carries the cadence loop's health as well — `late‰` of all
/// frames folded (a due time already past when the frame became presentable: the direct signal
/// the cushion is too small, and WP8's acceptance criterion), `jitter` (the loop residual's
/// mean absolute deviation, our first honest per-stream jitter number), `cushion` and
/// `reanchors`. Absent under latency, where there is no loop.
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
@@ -577,6 +694,21 @@ impl Presenter {
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
// Cumulative over the session rather than this window (the loop's counters survive
// `reset`): `late` is a RATE question, and one second of it is too few frames to read a
// sub-percent criterion off.
let cadence = self
.cadence_health()
.map(|h| {
format!(
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
h.late.saturating_mul(1000) / h.frames.max(1),
h.jitter_ns as f64 / 1e6,
h.cushion_ns as f64 / 1e6,
h.reanchors,
)
})
.unwrap_or_default();
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
@@ -584,7 +716,7 @@ impl Presenter {
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
vsyncMs={:.2} panelMs={:.2}{}",
self.released,
displays,
self.paced_drops,
@@ -607,6 +739,7 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence,
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
@@ -671,3 +804,108 @@ pub(super) fn presenter_disabled_by_sysprop() -> bool {
};
n > 0 && &buf[..n as usize] == b"arrival"
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE_HZ: u32 = 120;
const P: i64 = 8_333_333; // one 120 Hz source interval, ns
/// A host stamping realtime, played out against a monotonic present clock — two different
/// eras, which is the whole reason the loop estimates the offset rather than being told it.
const PTS0_NS: i64 = 1_786_000_000_000_000_000;
const MONO0: i64 = 987_000_000_000;
const PTS0_US: u64 = (PTS0_NS / 1000) as u64;
/// `k`'s pts as the CODEC echoes it — µs, truncated exactly as `feed_ready` queues it — and
/// the monotonic instant it became presentable, with `jitter_ns` of transport noise on the
/// arrival and none on the source stamp.
fn frame(k: i64, jitter_ns: i64) -> (u64, i64) {
(((PTS0_NS + k * P) / 1000) as u64, MONO0 + k * P + jitter_ns)
}
#[test]
fn the_latency_intent_never_consults_the_cadence_clock() {
let mut p = Presenter::new(PresentPriority::Latency, SOURCE_HZ);
for k in 0..600 {
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
assert_eq!(
p.due_at(pts_us, ready),
None,
"latency must produce no due time at all"
);
}
assert!(
p.cadence_health().is_none(),
"latency holds no loop to have health"
);
// …so the target floor is exactly what it was before the clock existed: this instant plus
// SurfaceFlinger's latch lead, and nothing else.
assert_eq!(p.not_before_ns(MONO0, None), MONO0 + p.margin_ns);
}
#[test]
fn the_smooth_intent_puts_every_decoded_frame_through_the_loop() {
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
for k in 0..600 {
let (pts_us, ready) = frame(k, (k % 11) * 400_000);
assert!(p.due_at(pts_us, ready).is_some());
}
let h = p.cadence_health().expect("smooth holds a loop");
assert_eq!(h.frames, 600);
assert_eq!(
h.reanchors, 1,
"only the cold start anchors on a clean trace"
);
}
#[test]
fn a_due_time_ahead_of_now_moves_the_target_and_one_behind_it_does_not() {
let p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
let floor = MONO0 + p.margin_ns;
// Due ahead: the release aims at the source's grid, which is the entire change.
assert_eq!(p.not_before_ns(MONO0, Some(floor + P)), floor + P);
// Due already past — a late frame, which the loop deliberately reports unclamped: present
// at the next opportunity, never drag the grid back to the frame.
assert_eq!(p.not_before_ns(MONO0, Some(floor - 5 * P)), floor);
}
#[test]
fn the_store_holds_a_frame_that_is_not_due_yet_and_releases_it_within_reach_of_its_slot() {
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
let due = MONO0 + 4 * P;
p.frames.push_back(HeldFrame {
index: 0,
pts_us: PTS0_US,
decoded_ns: 0,
due_ns: Some(due),
});
assert!(!p.head_is_releasable(MONO0, P), "four refreshes early");
assert!(
!p.head_is_releasable(due - P - p.margin_ns - 1, P),
"one nanosecond outside the submit reach of its own slot"
);
assert!(
p.head_is_releasable(due - P - p.margin_ns, P),
"exactly one panel period of reach, the earliest that still buys nothing to wait"
);
assert!(
p.head_is_releasable(due + 10 * P, P),
"late frames go at once"
);
}
#[test]
fn a_frame_carrying_no_due_time_is_always_releasable() {
// `HeldFrame` is shared with the latency intent, where the due time is always absent, so
// the predicate has to drain on a frame it cannot answer for rather than wedge behind it.
let mut p = Presenter::new(PresentPriority::Smooth { buffer: 2 }, SOURCE_HZ);
p.frames.push_back(HeldFrame {
index: 0,
pts_us: PTS0_US,
decoded_ns: 0,
due_ns: None,
});
assert!(p.head_is_releasable(MONO0, P));
}
}
+21 -14
View File
@@ -93,16 +93,24 @@ impl VsyncShared {
self.panel_period_ns.load(Ordering::Relaxed)
}
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
/// The release target for a frame that must not be presented before `not_before_ns`: the
/// earliest stored timeline whose EXPECTED PRESENT is past that instant, extrapolated forward
/// by whole periods once the stored set has aged out (timelines refresh once per vsync
/// callback; a frame can decode anywhere inside that window). `None` on the 31/32 fallback —
/// the caller releases ASAP.
///
/// The floor is the CALLER's to compose because two constraints meet in it and only the
/// caller knows the second: SurfaceFlinger's latch lead (`now + margin`, always) and, under
/// cadence targeting, the frame's own due time on the source's timeline. Adding the submit
/// lead here to a due time that is already an absolute present instant would push a frame due
/// just under a grid point onto the next one for some phases and not others — judder rather
/// than latency, which is the defect the due time exists to remove.
///
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller
/// folds into the floor. Targeting by deadline cost every frame an extra refresh of waiting
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
/// frame presents one vsync later — exactly what the conservative gate always paid.
///
@@ -110,10 +118,9 @@ impl VsyncShared {
/// at the app's assigned render rate, but the panel latches at its own — when the app is
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
/// by whole panel periods (while its present still clears the margin) restores the true
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
/// a no-op.
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
/// by whole panel periods (while its present still clears the floor) restores the true grid;
/// when callbacks run at the panel rate the pull condition is never true and this is a no-op.
pub(super) fn next_target(&self, not_before_ns: i64) -> Option<FrameTimeline> {
let mut t = {
let g = self
.timelines
@@ -121,7 +128,7 @@ impl VsyncShared {
.unwrap_or_else(std::sync::PoisonError::into_inner);
let found = g
.iter()
.find(|t| t.expected_present_ns > now_ns + margin_ns)
.find(|t| t.expected_present_ns > not_before_ns)
.copied();
match found {
Some(t) => t,
@@ -132,8 +139,8 @@ impl VsyncShared {
return None;
}
// All stored timelines have passed — step the last one forward whole
// periods until its present clears `now + margin` again.
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
// periods until its present clears the floor again.
let behind = not_before_ns.saturating_sub(last.expected_present_ns);
let k = behind / period + 1;
FrameTimeline {
expected_present_ns: last.expected_present_ns + k * period,
@@ -144,7 +151,7 @@ impl VsyncShared {
};
let panel = self.panel_period_ns.load(Ordering::Relaxed);
if panel > 0 {
while t.expected_present_ns - panel > now_ns + margin_ns {
while t.expected_present_ns - panel > not_before_ns {
t.deadline_ns -= panel;
t.expected_present_ns -= panel;
}
@@ -48,11 +48,17 @@ final class AudioRing: @unchecked Sendable {
/// gaps per 10 minutes at a 5 ms quantum, against 3 at 8 ms and 1 at 16 ms on an identical
/// link. Mirrors `JitterTuning::COREAUDIO.deprime_ms`.
private static let deprimeMS = 60
/// How long a packet DROUGHT may be concealed (`DroughtConceal`) before this ring is allowed
/// to underrun and the hysteresis above is allowed to run: twice that window long enough to
/// ride out the delivery stalls that de-prime rings today, short enough that a genuinely dead
/// stream is not papered over. DERIVED from the fuse rather than written out, so it cannot
/// drift away from the thing it exists to protect. Mirrors `JitterTuning::plc_max_ms`.
static let plcMaxMS = deprimeMS * 2
/// Floor in callbacks under `deprimeMS`, so a large-quantum device keeps real hysteresis
/// instead of de-priming on the first short read. Mirrors `MIN_DEPRIME_CALLBACKS`.
private static let minDeprimeCallbacks = 2
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
private static let frameMS = 5
static let frameMS = 5
/// Depth average must exceed target by this before drift correction fires the middle of the
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
private static let shedExcessMS = 15
@@ -151,6 +157,10 @@ final class AudioRing: @unchecked Sendable {
/// no timestamps, so the drain thread (which has both a packet's `pts_ns` and the video leg)
/// hands the number back for reporting. Mirrors `NativeClient::audio_av_offset_ms`.
private var avOffsetMS = 0
/// Drought concealment the drain thread has synthesized this session, ms STORED here for the
/// same reason `avOffsetMS` is: the ring cannot compute it, but it is where the numbers a
/// listener's complaint needs can be read under one lock.
private var plcMS = 0
private let channels: Int
private let perMS: Int
private let lock = OSAllocatedUnfairLock()
@@ -224,6 +234,16 @@ final class AudioRing: @unchecked Sendable {
avOffsetMS = ms
}
/// Store the drain thread's running drought concealment (`DroughtConceal.totalMS`) for
/// reporting. Concealment that nobody can see is concealment that hides the bug it is
/// covering: a healthy `underruns` bought with a climbing `plc_ms` is a link in trouble, not
/// a link that is fine.
func notePlcMS(_ ms: Int) {
lock.lock()
defer { lock.unlock() }
plcMS = ms
}
/// Buffered depth in interleaved samples what the sync loop measures against (`bufferedMS`
/// is the same quantity rounded for humans). Everything queued here must play before the frame
/// the drain thread is about to write, which is exactly what delays it.
@@ -481,6 +501,10 @@ final class AudioRing: @unchecked Sendable {
/// Reported next to the depth, never instead of it: a deep ring on a jittery link is
/// CORRECT behaviour, and only the offset separates that from a ring holding audio late.
let avOffsetMS: Int
/// Audio synthesized for packet droughts this session (`DroughtConceal`), ms read next
/// to `underruns`, which it exists to prevent, because the two only mean something
/// together.
let plcMS: Int
}
var stats: Stats {
@@ -491,7 +515,8 @@ final class AudioRing: @unchecked Sendable {
targetMS: target / max(perMS, 1),
underruns: underrunCount,
sheds: shedCount,
avOffsetMS: avOffsetMS)
avOffsetMS: avOffsetMS,
plcMS: plcMS)
}
}
@@ -641,6 +666,70 @@ struct AvSync {
}
}
// MARK: - Drought concealment
/// Bounded concealment of a packet DROUGHT the Apple leg of the policy the three Rust clients
/// share (`punktfunk_core::audio::DroughtConceal`; design/host-source-stutter-fixes.md, WP-C1).
///
/// The decode path already conceals a SEQ GAP: core's in-ABI decoder synthesizes the packets the
/// sequence says went missing before the one that arrived (`nextAudioPcm`). But that only fires
/// when a LATER packet arrives to reveal the gap. When the wire simply goes quiet a delivery
/// stall on a bunching Wi-Fi link, or a host whose capture stalled nothing arrives to reveal
/// anything: `AudioRing` drains to empty, the render callback runs short, and `noteRead` de-primes
/// and then re-primes a whole target's worth of fresh silence. The artifact is far longer than the
/// audio actually missing, and this is the shape the 2026-08-15 field session spent 316 % of its
/// wall-clock in.
///
/// So a drought that is draining the ring gets concealed too, from the same decoder state
/// (`PunktfunkConnection.audioPlc`), for a bounded time. Denominated in TIME, never in frames or
/// callbacks: that is the recorded lesson from the very fuse this protects, where a count gave an
/// iPad a third of a Mac's slack (`AudioRing.deprimeMS`, and
/// `testDeprimeFuseIsADurationNotACallbackCount`).
///
/// Time is passed IN, so the policy stays as deterministic as the ring's own.
struct DroughtConceal {
/// A drought must outlast ordinary arrival jitter before anything is synthesized for it: two
/// protocol frames, the same tolerance the host's capture-hole infill uses at the other end.
private static let afterMS = 2 * AudioRing.frameMS
/// and the ring must actually be running out. A drought a deep ring can cover is not audible,
/// and concealing it would synthesize audio the late packets are about to duplicate pushing
/// the whole stream later and handing the drift shed a mess to clean up audibly.
private static let floorMS = 2 * AudioRing.frameMS
/// Concealed since the last real packet.
private var concealedMS = 0
private let maxMS: Int
/// Concealed over the session what the 10 s `plc_ms=` line reports. Concealment must be
/// visible: a policy that quietly papers over a failing link is a policy that hides the bug.
private(set) var totalMS = 0
init(maxMS: Int) {
self.maxMS = maxMS
}
/// A packet arrived, ending any drought the next one starts from a full budget.
///
/// The Rust twin also hands back the frames it concealed, for its caller to subtract from the
/// loss concealment the seq path is about to ask for. Here that subtraction is core's, on the
/// far side of the ABI, because that is where the gap tracker lives (see
/// `punktfunk_connection_audio_plc`) a packet genuinely lost inside a covered drought must
/// not be concealed twice either way.
mutating func packet() {
concealedMS = 0
}
/// Should one more frame be concealed? `depthMS` is the playout ring as the render callback
/// last left it.
mutating func conceal(sinceLastPacketMS: Int, depthMS: Int) -> Bool {
if sinceLastPacketMS < Self.afterMS || depthMS > Self.floorMS || concealedMS >= maxMS {
return false
}
concealedMS += AudioRing.frameMS
totalMS += AudioRing.frameMS
return true
}
}
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
/// stereo (the standard layout is correct). For 5.1/7.1 we list explicit channel labels via
/// `kAudioChannelLayoutTag_UseChannelDescriptions` preset tags (DTS_5_1 etc.) don't reliably
@@ -1030,6 +1030,16 @@ public final class SessionAudio {
defer { drainDone.signal() }
var drained = 0
var av = AvSync(channels: channels)
// WP-C1 the drought half of concealment. Core heals a SEQ GAP, but only when a later
// packet arrives to reveal it; when the wire simply goes quiet nothing arrives to
// reveal anything, and the ring drains into an underrun and a de-prime whose re-prime
// is a longer artifact than the audio that was missing.
var drought = DroughtConceal(maxMS: AudioRing.plcMaxMS)
var lastPacketNs = DispatchTime.now().uptimeNanoseconds
// Something has decoded, so there is both state to conceal from and continuity to
// hold. Until then a session whose host never sends audio keeps the long timeout below
// rather than waking two hundred times a second to do nothing.
var decoded = false
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order.
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
@@ -1038,11 +1048,48 @@ public final class SessionAudio {
alive = autoreleasepool { () -> Bool in
let pcm: PunktfunkConnection.AudioPCM?
do {
pcm = try connection.nextAudioPcm(timeoutMs: 100)
// Wait at most one frame WHILE there is a stream to protect: the drought
// decision has to be made on the wire's schedule, not whenever the next packet
// happens to turn up.
pcm = try connection.nextAudioPcm(
timeoutMs: decoded ? UInt32(AudioRing.frameMS) : 100)
} catch {
return false // session closed
}
guard let pcm, pcm.frameCount > 0 else { return true }
guard let pcm, pcm.frameCount > 0 else {
// Nothing on the wire. If the ring is draining with it, conceal from the
// decoder's own state the same libopus interpolation the loss path uses,
// bounded by this ring's de-prime fuse so a genuinely dead stream is not
// papered over. ONE frame per tick, not a burst: this arm runs every frame,
// which is the rate the callback drains at, so concealment keeps pace with
// playout instead of racing ahead of a depth reading it has already
// invalidated.
guard decoded else { return true }
let quietMS = Int(
(DispatchTime.now().uptimeNanoseconds &- lastPacketNs) / 1_000_000)
guard drought.conceal(sinceLastPacketMS: quietMS, depthMS: ring.bufferedMS)
else {
return true
}
let plc: PunktfunkConnection.AudioPCM?
do {
plc = try connection.audioPlc()
} catch {
return false // session closed
}
if let plc {
plc.samples.withUnsafeBufferPointer { p in
if let base = p.baseAddress {
ring.write(base, count: plc.frameCount * plc.channels)
}
}
}
ring.notePlcMS(drought.totalMS)
return true
}
decoded = true
lastPacketNs = DispatchTime.now().uptimeNanoseconds
drought.packet()
// Place this frame against the picture it belongs with BEFORE queueing it: the
// depth read here is everything that must still play first, which is exactly what
// delays it. Skipped wholesale when no meter was wired, so an un-armed session
@@ -1070,12 +1117,14 @@ public final class SessionAudio {
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
// log buffer depth and underruns; without this an Apple audio report latency or
// dropout arrives with no numbers at all, which is the position every platform
// was in before the 2026-08 audio work.
// was in before the 2026-08 audio work. `plc_ms` rides along because a healthy
// `underruns` bought with a climbing `plc_ms` is a link in trouble, not a link
// that is fine.
drained += 1
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS)"
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS) plc_ms=\(s.plcMS)"
)
}
return true
@@ -1139,6 +1139,46 @@ public final class PunktfunkConnection {
}
}
/// Synthesize one frame of concealment from the in-core decoder's own state no packet
/// involved, nothing pulled off the wire. `nil` when there is nothing to extrapolate from
/// (before the first decode, or when libopus declines), which the caller treats exactly like a
/// timeout: write nothing this tick. Throws `.closed` once the session ended.
///
/// `nextAudioPcm` heals a gap the SEQUENCE reveals; that needs a later packet to arrive and
/// reveal it. This is for the wire simply going quiet, where nothing arrives to reveal
/// anything and the ring drains into an underrun and a de-prime whose re-prime is a longer
/// artifact than the audio that was missing. `DroughtConceal` owns WHEN to ask bounded in
/// time, and only while the ring is genuinely running out.
///
/// Same audio thread as `nextAudioPcm`, whose borrowed buffer this call invalidates (they
/// share the slot). The returned `samples` are copied out. `ptsNs`/`seq` read 0: this frame was
/// never on the wire, so it has no capture instant and must not reach an `AvSync` observation.
public func audioPlc() throws -> AudioPCM? {
audioLock.lock()
defer { audioLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var out = PunktfunkAudioPcm()
let rc = punktfunk_connection_audio_plc(h, &out)
switch rc {
case statusOK:
let channels = Int(out.channels)
let total = Int(out.frame_count) * channels
guard let base = out.samples, total > 0 else { return nil }
// Copy: the pointer borrows connection memory only until the next PCM call.
let samples = Array(UnsafeBufferPointer(start: base, count: total))
return AudioPCM(
samples: samples, frameCount: Int(out.frame_count),
channels: channels, ptsNs: out.pts_ns, seq: out.seq)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
@@ -162,6 +162,28 @@ public final class FrameStore<Frame>: @unchecked Sendable {
return f
}
/// Cadence-driven take: hand back the oldest frame only once its DUE time has arrived, so the
/// store's job becomes "hold what is not due yet" instead of "release one per present
/// opportunity" (design/presenter-cadence-rework.md §4.3). `due` projects the frame's due
/// instant on the same clock as `now`; nil (no cadence estimate for that frame) means due
/// immediately.
///
/// The preroll gate does not apply here. It exists only to build headroom for a per-slot
/// drain, and under cadence targeting the cushion IS the headroom prerolling on top would
/// stack `capacity 1` frames of standing latency the user never asked for. `underflows` is
/// not counted either: an empty store is the normal steady state once frames are held until
/// due, so the honest starvation signal is `CadenceHealth.late` (the due time had already
/// passed when the frame became presentable), not a run-dry count.
func take(dueBy now: CFTimeInterval, due: (Frame) -> CFTimeInterval?) -> Frame? {
lock.lock()
defer { lock.unlock() }
guard let oldest = frames.first else { return nil }
if let at = due(oldest), at > now { return nil } // held not due yet
if isFifo { return frames.removeFirst() }
frames.removeAll(keepingCapacity: true)
return oldest
}
/// Return a frame the render thread took but could not present (no drawable yet, or a
/// transient render failure). Newest-wins keeps it only while the slot is still empty a
/// newer decoded frame wins; FIFO reinserts it at the FRONT (it is the oldest; a transient
@@ -205,6 +227,269 @@ private final class VsyncClock: @unchecked Sendable {
}
}
/// Tuning for one cadence loop. Gains are SHIFT COUNTS the loop is fixed-point Int64
/// throughout, so it runs identically on every client and in the offline harness, and carries no
/// float into a present path.
///
/// **These values are provisional, and saying so is part of the design.** The plan asks for
/// constants fitted to recorded `(src_pts, received, decoded)` traces (its spike S2), and S2 was
/// never run. What is here is derived from first principles a proportional time constant of tens
/// of frames, an integral an order slower, a cushion of a few mean-absolute-deviations and the
/// first real trace should replace them.
///
/// A hand-written port of `punktfunk_core::phase::CadenceTuning`, exactly as `AudioRing` is a
/// port of `JitterPolicy`: this pipeline is Swift and does not link that Rust type. **Every
/// constant and rule here must stay in lockstep with it** `CadenceClockTests` and the Rust
/// `phase::tests` assert the two against the same synthetic input, and that agreement IS the
/// contract.
struct CadenceTuning: Equatable {
/// Proportional gain on the offset estimate: `1 >> offsetShift` of the residual per frame.
var offsetShift: UInt8
/// Integral gain on the per-frame rate (skew) term: `1 >> skewShift`.
var skewShift: UInt8
/// EMA weight for the residual mean-absolute-deviation.
var jitterShift: UInt8
/// Per-sample residual clamp one outlier must not yank the estimate.
var errorClampNs: Int64
/// Cushion = `mad * cushionNum / cushionDen`, clamped to
/// `[cushionFloorNs, frameIntervalNs]`.
var cushionNum: UInt16
var cushionDen: UInt16
var cushionFloorNs: Int64
/// Source-timestamp gap beyond which the loop re-anchors instead of tracking.
var reanchorGapNs: Int64
/// For callers that snap the due time onto a display grid afterwards: the snap-up itself
/// carries roughly half a refresh of implicit slack, so the cushion can be small. Every Apple
/// present snaps onto `VsyncClock.nextVsync` under arrival/glass pacing, onto the
/// CAMetalDisplayLink's own vend under deadline pacing so this is the one this client runs.
static func snapping() -> CadenceTuning {
CadenceTuning(
offsetShift: 5, skewShift: 10, jitterShift: 5, errorClampNs: 20_000_000,
cushionNum: 2, cushionDen: 1, cushionFloorNs: 500_000,
reanchorGapNs: 500_000_000)
}
/// For callers presenting at the due time directly (VRR, direct scanout): no implicit slack,
/// so the cushion must cover more of the distribution on its own.
static func freeRunning() -> CadenceTuning {
var t = snapping()
t.cushionNum = 3
t.cushionFloorNs = 2_000_000
return t
}
}
/// Loop health for the pf-present line the numbers that say whether the cushion is doing its
/// job. Mirrors `punktfunk_core::phase::CadenceHealth`.
///
/// Residual PERCENTILES are deliberately absent: this type holds no histogram, and the client
/// stat paths (the latency meters) are where distributions belong.
struct CadenceHealth: Equatable {
/// Frames folded since the last `reset`.
var frames: UInt64 = 0
/// of which the due time was already past when the frame became presentable. The direct
/// signal that the cushion is too small.
var late: UInt64 = 0
/// Times the loop gave up tracking and re-anchored (gap, regression, or explicit reset).
var reanchors: UInt64 = 0
var offsetNs: Int64 = 0
var skewNs: Int64 = 0
var jitterNs: Int64 = 0
var cushionNs: Int64 = 0
}
/// Plays frames out on the SOURCE's cadence instead of on their arrival instant.
///
/// The defect it exists for: every client presents a frame as soon as it is decoded, so the
/// transport's jitter and, on a host whose compositor delivers raggedly, the compositor's
/// lands on the glass 1:1. The 2026-08-15 Skynet field log has KWin's screencast arriving
/// 0.118.22 ms off its own grid (up to a full 120 Hz period) for 24 minutes on a session with the
/// bitrate pinned and zero loss. The loop estimates the offset between the source clock and the
/// present clock and hands back a due time on the source's own timeline plus a cushion sized to
/// the measured jitter.
///
/// **Type-2 on purpose.** It tracks offset *and* per-frame rate, because two free-running crystals
/// produce a ramp and a proportional-only loop lags a ramp forever.
///
/// **It smooths the offset, never the timestamps.** Due is `srcPts + offset + cushion`, so genuine
/// variation in the source's own cadence a variable-rate renderer, an irregular capture tick
/// passes straight through, and only the transport's contribution to `ready pts` is filtered.
/// Anything that made due times more evenly spaced than the source would be a bug.
///
/// **Domain-agnostic by construction.** A constant offset between clock domains is absorbed by the
/// offset estimator, so a caller feeds `readyNs` and reads the due time in ONE domain with no
/// conversion anywhere in this path. On Apple that domain is `CACurrentMediaTime` the clock
/// `presentAtMediaTime` consumes so the decode-output instant is converted ONCE on the way in
/// (`Stage2Pipeline.mediaTimeNs(forRealtimeNs:)`) and the due time comes back needing none.
/// Suspend/resume breaks the constant; the gap re-anchor below is what covers it.
///
/// A late frame's due time is returned in the PAST, unclamped: clamping it to `readyNs` would
/// quietly turn every late frame into a fresh anchor, which is precisely the arrival-driven
/// presentation this exists to stop being.
///
/// Prior art is ordinary and old: MPEG-2 TS PCR recovery and RTP playout scheduling (RFC 3550
/// §6.4.1 carries the jitter estimator this MAD mirrors).
///
/// A hand-written port of `punktfunk_core::phase::CadenceClock` see `CadenceTuning` for the
/// lockstep contract. Sendable; lock-guarded the decode-completion thread folds frames while the
/// render thread reads health.
final class CadenceClock: @unchecked Sendable {
private let lock = NSLock()
private let tuning: CadenceTuning
/// `ready srcPts`, smoothed. Absorbs the clock-domain constant.
private var offsetNs: Int64 = 0
/// Per-frame drift of that offset the integral term.
private var skewNs: Int64 = 0
/// EMA of |residual|, the cushion's input.
private var madNs: Int64 = 0
/// nil until the first sample anchors the loop.
private var lastPtsNs: UInt64?
/// Last frame interval seen, so `cushionNs` can apply its ceiling.
private var frameIntervalNs: Int64 = 0
private var counters = CadenceHealth()
init(tuning: CadenceTuning) {
self.tuning = tuning
}
/// Force a re-anchor on the next sample. Call on every discontinuity the client already knows
/// about: reanchor, codec rebuild, surface recreate, jump-to-live, resume.
func reset() {
lock.lock()
lastPtsNs = nil
skewNs = 0
// `madNs` deliberately SURVIVES. It describes the link, not the stream, and a cushion that
// collapsed to its floor at every rebuild would spend the next few hundred frames
// presenting late the exact failure the cushion exists to prevent.
lock.unlock()
}
/// Fold one presentable frame and return when it is due, in the present clock domain.
///
/// `readyNs` is when the frame became presentable; `frameIntervalNs` is the nominal source
/// interval and the cushion's ceiling.
///
/// The result **may be earlier than `readyNs`** that is a late frame, and the caller's
/// contract is "already due present at the next opportunity", never "drag the grid back to
/// now".
func dueNs(srcPtsNs: UInt64, readyNs: Int64, frameIntervalNs: Int64) -> Int64 {
lock.lock()
defer { lock.unlock() }
self.frameIntervalNs = frameIntervalNs
counters.frames += 1
let pts = Int64(bitPattern: srcPtsNs)
let raw = saturatingSub(readyNs, pts)
let anchored: Bool
if let last = lastPtsNs {
// Source time going BACKWARDS, or a gap so long the estimate cannot be trusted to
// have tracked across it: re-anchor rather than slew for seconds.
anchored =
!(srcPtsNs < last
|| srcPtsNs - last > UInt64(bitPattern: tuning.reanchorGapNs))
} else {
anchored = false
}
if anchored {
// Advance the estimate one frame on the rate term, then correct it by a bounded
// fraction of what the new sample says.
offsetNs = saturatingAdd(offsetNs, skewNs)
let err = min(
max(saturatingSub(raw, offsetNs), -tuning.errorClampNs), tuning.errorClampNs)
offsetNs = saturatingAdd(offsetNs, shrTowardZero(err, tuning.offsetShift))
skewNs = saturatingAdd(skewNs, shrTowardZero(err, tuning.skewShift))
let dev = abs(err) - madNs
madNs = saturatingAdd(madNs, shrTowardZero(dev, tuning.jitterShift))
} else {
offsetNs = raw
skewNs = 0
counters.reanchors += 1
}
lastPtsNs = srcPtsNs
let due = saturatingAdd(saturatingAdd(pts, offsetNs), lockedCushionNs())
if due < readyNs { counters.late += 1 }
return due
}
/// A frame whose timestamp is not on the source cadence a repeat the host re-anchored at
/// submit, a stamp its plausibility gate replaced with "now", or one that reached us with no
/// usable pts at all. Those samples do not lie on the source's timeline, and folding them in
/// would drag the offset estimate toward "now" exactly when the stream is idle and the
/// estimate matters most.
///
/// Returns a due time from the CURRENT estimate, leaving offset, skew and jitter untouched:
/// the frame is simply due once it is ready, cushioned like any other.
func noteOffCadence(readyNs: Int64, frameIntervalNs: Int64) -> Int64 {
lock.lock()
defer { lock.unlock() }
self.frameIntervalNs = frameIntervalNs
return saturatingAdd(readyNs, lockedCushionNs())
}
func jitterNs() -> Int64 {
lock.lock()
defer { lock.unlock() }
return madNs
}
/// How far past the estimate a frame is held, to absorb the measured jitter.
///
/// The one-frame-interval ceiling is an INVARIANT, not a tunable: a cushion past a whole frame
/// buys latency for smoothness the source cannot supply, and at that point the honest fix is a
/// deeper buffer the user asked for, not a loop quietly holding frames.
func cushionNs() -> Int64 {
lock.lock()
defer { lock.unlock() }
return lockedCushionNs()
}
func health() -> CadenceHealth {
lock.lock()
defer { lock.unlock() }
var h = counters
h.offsetNs = offsetNs
h.skewNs = skewNs
h.jitterNs = madNs
h.cushionNs = lockedCushionNs()
return h
}
private func lockedCushionNs() -> Int64 {
let den = Int64(max(tuning.cushionDen, 1))
let want = saturatingMul(madNs, Int64(tuning.cushionNum)) / den
let ceiling = frameIntervalNs > 0 ? frameIntervalNs : Int64.max
return min(max(want, min(tuning.cushionFloorNs, ceiling)), ceiling)
}
}
/// Arithmetic shift that rounds toward ZERO, so a negative residual is damped by exactly as much
/// as its positive twin. A plain `>>` rounds toward , which biases a loop that spends its whole
/// life within a few nanoseconds of zero error.
private func shrTowardZero(_ v: Int64, _ shift: UInt8) -> Int64 {
v < 0 ? -((-v) >> shift) : v >> shift
}
/// The Rust loop is `saturating_*` throughout a garbage timestamp must clamp the estimate, never
/// trap the render thread. Swift's operators trap instead of saturating, so the port carries its
/// own; for every reachable input these are plain `+`, ``, `×`.
private func saturatingAdd(_ a: Int64, _ b: Int64) -> Int64 {
let (v, overflow) = a.addingReportingOverflow(b)
return overflow ? (b > 0 ? Int64.max : Int64.min) : v
}
private func saturatingSub(_ a: Int64, _ b: Int64) -> Int64 {
let (v, overflow) = a.subtractingReportingOverflow(b)
return overflow ? (b > 0 ? Int64.min : Int64.max) : v
}
private func saturatingMul(_ a: Int64, _ b: Int64) -> Int64 {
let (v, overflow) = a.multipliedReportingOverflow(by: b)
guard overflow else { return v }
return (a > 0) == (b > 0) ? Int64.max : Int64.min
}
/// When a ready frame is pushed to the layer the stage-2 vs stage-3 presenter split. Same decode
/// half, same newest-wins ring; only the present cadence differs.
///
@@ -329,7 +614,8 @@ public final class PresentLinkInfo: @unchecked Sendable {
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// Deadline pacing's staged frame-rate hint, and on every pacing the session's nominal source
/// interval (`sourceIntervalNs`). SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
/// floor is PINNED at the stream rate no idle ramp-down: with a low floor the link idles toward
@@ -369,6 +655,16 @@ private final class FrameRateHint: @unchecked Sendable {
pending = nil
return p
}
/// The nominal SOURCE interval in nanoseconds the cadence clock's cushion ceiling. Read on
/// every pacing, not just deadline: this box is where the negotiated stream rate already
/// lives, staged from main on session start and every Reconfigure, and the decode-completion
/// thread needs it under a lock. 0 = not known yet, which the clock handles by running its
/// cushion uncapped (the shared core's own behaviour for a zero interval).
func sourceIntervalNs() -> Int64 {
lock.lock()
defer { lock.unlock() }
return streamHz > 0 ? Int64(1_000_000_000.0 / Double(streamHz)) : 0
}
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
#if os(tvOS)
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
@@ -663,6 +959,11 @@ final class PresentGate: @unchecked Sendable {
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
/// multiples; immediate flips scatter). Lock-guarded `presented` lands on a Metal callback thread.
private final class PresentDebugStats: @unchecked Sendable {
/// The session's cadence loop, for the line's `cadence` segment `nil` under the latency
/// intent, and then the line is emitted exactly as it was before source-timestamp playout
/// existed. `late` is the number WP8 gates on: a due time already past when the frame became
/// presentable is the direct signal that the cushion is too small.
private let cadence: CadenceClock?
private let lock = NSLock()
private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
@@ -685,6 +986,10 @@ private final class PresentDebugStats: @unchecked Sendable {
private var inFlight = 0
private var maxInFlight = 0
init(cadence: CadenceClock?) {
self.cadence = cadence
}
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
/// A wake that found the stage-3 gate closed (a present still in flight) the frame stays in
@@ -743,6 +1048,17 @@ private final class PresentDebugStats: @unchecked Sendable {
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
let vendMax = vends.last ?? 0
let inflightMax = maxInFlight
// Loop health, appended only where a loop exists `late`/`frames` is WP8's cushion
// criterion and `reanchor` says whether the estimate is tracking at all.
let loop = cadence?.health()
let cadenceLine =
loop.map {
String(
format: " cadence late=%llu/%llu reanchor=%llu jitterUs=%lld cushionUs=%lld "
+ "skewNs=%lld",
$0.late, $0.frames, $0.reanchors, $0.jitterNs / 1000, $0.cushionNs / 1000,
$0.skewNs)
} ?? ""
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
@@ -751,7 +1067,7 @@ private final class PresentDebugStats: @unchecked Sendable {
decoded, ok, failed, empty, gated, noDrawable, dropped,
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
vendP50, vendMax) + cadenceLine
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
@@ -806,6 +1122,11 @@ public final class Stage2Pipeline {
/// at most one per vsync, so the FIFO store drains on the display's cadence rather than on
/// arrival. Ignored under `.deadline` (the link IS the cadence there).
private let vsyncPaced: Bool
/// Source-timestamp playout for the SMOOTHNESS intent: every decoded frame is stamped with
/// when it is due on the host's own cadence, and the present decision aims there instead of at
/// the moment the frame happened to decode. `nil` under `latency`, whose path keeps no cadence
/// arithmetic in it at all see the intent gate in `init`.
private let cadence: CadenceClock?
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
@@ -885,12 +1206,23 @@ public final class Stage2Pipeline {
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
// The intent gate: source-timestamp playout is what `smooth` MEANS now, and `latency` is
// defined as arrival-driven with no cushion so the store policy, which is the intent's
// only other expression (`PresentPriority.storePolicy`: smooth fifo, latency
// newest-wins), is what decides whether a clock exists at all. A latency session runs the
// same present path it ran before this existed.
switch storePolicy {
case .newestWins: self.cadence = nil
case .fifo: self.cadence = CadenceClock(tuning: .snapping())
}
let ring = ring
let recovery = recovery
let renderSignal = renderSignal
let gate = gate
let decodeReport = decodeReport
let phaseReporter = phaseReporter
let cadence = cadence
let rateHint = frameRateHint
self.decoder = VideoDecoder(
onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
@@ -911,7 +1243,11 @@ public final class Stage2Pipeline {
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
guard gate.onDecoded(flags: frame.flags) else { return }
ring.submit(frame)
// Decoder OUTPUT is where the cadence loop is sampled the instant the frame
// becomes presentable. Receipt would not model decode at all and could hand back a
// due time already past by the moment the frame exists; dequeue would fold the
// present path's own wait into the estimate and make the loop chase its output.
ring.submit(Stage2Pipeline.dated(frame, by: cadence, hint: rateHint))
// FRAME ARRIVAL is the render trigger (never the display link see the header).
renderSignal.signal()
},
@@ -936,6 +1272,11 @@ public final class Stage2Pipeline {
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
// A fresh session is a fresh source clock: re-anchor on its first frame rather than slew
// for seconds off the previous host's offset. (Mid-session discontinuities background
// resume, a stream idle under the infinite GOP arrive as a source-timestamp gap the loop
// re-anchors on by itself; this seam covers the one it cannot see.)
cadence?.reset()
token = StopFlag() // fresh token per start a stop is permanent (like StreamPump)
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
@@ -962,7 +1303,7 @@ public final class Stage2Pipeline {
connection: connection, token: token, pumpStopped: pumpStopped,
ring: ring, renderSignal: renderSignal,
device: presenter.metalDevice, queue: presenter.metalQueue,
decodeMeter: decodeMeter,
decodeMeter: decodeMeter, cadence: cadence, rateHint: frameRateHint,
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
} else {
thread = Thread {
@@ -1088,7 +1429,8 @@ public final class Stage2Pipeline {
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
// streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
let debugStats =
(presentDebug || pacing == .deadline) ? PresentDebugStats(cadence: cadence) : nil
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
@@ -1118,6 +1460,12 @@ public final class Stage2Pipeline {
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
// Cadence targeting turns the store into a holding buffer: a frame comes out once it is
// DUE, not once a present opportunity exists (§4.3). The latency intent has no clock and
// keeps the unconditional take, byte for byte.
let takeReady: () -> ReadyFrame? = cadence == nil
? { ring.take() }
: { ring.take(dueBy: CACurrentMediaTime(), due: { $0.dueMediaTime }) }
let renderThread = Thread {
defer { renderStopped.signal() }
// macOS smoothness: the vsync this thread last presented onto at most ONE present
@@ -1150,7 +1498,7 @@ public final class Stage2Pipeline {
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
guard !token.isStopped, let frame = ring.take() else {
guard !token.isStopped, let frame = takeReady() else {
gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
@@ -1158,8 +1506,14 @@ public final class Stage2Pipeline {
}
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link
// immediate see VsyncClock). OFF: flip as soon as the GPU finishes.
//
// Under cadence targeting the grid is entered at the frame's DUE time rather than
// at this instant, so two frames the host emitted one period apart land one period
// apart on glass however unevenly they arrived. Never before `now`: a due time in
// the past means the frame is late, not that the grid moves back.
let now = CACurrentMediaTime()
let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
? vsyncClock.nextVsync(after: max(now, frame.dueMediaTime ?? now)) : nil
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
@@ -1238,6 +1592,13 @@ public final class Stage2Pipeline {
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
// Cadence targeting under deadline pacing: the link's vend IS the grid snap, so the clock
// only has to hold a frame back until it is due and the next update presents it at most
// one refresh later. Same holding-buffer rule as the arrival/glass loop (§4.3); latency
// sessions have no clock and take unconditionally.
let takeReady: () -> ReadyFrame? = cadence == nil
? { ring.take() }
: { ring.take(dueBy: CACurrentMediaTime(), due: { $0.dueMediaTime }) }
// Shrink the drawable pool to 2 for THIS pacing the measured fix for a present floor
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
@@ -1328,7 +1689,7 @@ public final class Stage2Pipeline {
// layer's CURRENT config, so drawableSize/format have to be right before a vend
// can succeed at all (see reconcileLayer the session-start bootstrap, where
// the layer still has its initial 0×0 size and every vend fails allocation).
guard !token.isStopped, let frame = ring.take() else {
guard !token.isStopped, let frame = takeReady() else {
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
@@ -1409,8 +1770,9 @@ public final class Stage2Pipeline {
/// MAIN thread (SessionPresenter session start + every layout/Reconfigure): hint the
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
/// is the hinted link.
/// `FrameRateHint`). Under arrival/glass pacing no link reads it the hosting view's
/// CADisplayLink is the hinted one there but the stored rate is still the cadence clock's
/// nominal source interval, and hence its cushion ceiling, on every pacing.
public func setFrameRateHint(hz: Float) {
frameRateHint.stage(hz: hz)
}
@@ -1489,7 +1851,7 @@ public final class Stage2Pipeline {
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
device: MTLDevice, queue: MTLCommandQueue,
decodeMeter: LatencyMeter?,
decodeMeter: LatencyMeter?, cadence: CadenceClock?, rateHint: FrameRateHint,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)?
@@ -1547,10 +1909,15 @@ public final class Stage2Pipeline {
Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
decodeMeter?.record(
ptsNs: UInt64(receivedNs), atNs: decodedNs, offsetNs: 0)
// Same cadence sample as the VideoToolbox half: the wavelet decode's
// completion IS this frame's presentable instant.
ring.submit(
ReadyFrame(
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
image: .planar(planes), flags: flags))
Stage2Pipeline.dated(
ReadyFrame(
ptsNs: ptsNs, receivedNs: receivedNs,
decodedNs: decodedNs, image: .planar(planes),
flags: flags),
by: cadence, hint: rateHint))
renderSignal.signal()
}
if submitted {
@@ -1585,5 +1952,50 @@ public final class Stage2Pipeline {
let realtimeNow = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
return realtimeNow + Int64((t - caNow) * 1_000_000_000)
}
/// The exact inverse: a client `CLOCK_REALTIME` nanosecond instant (`ReadyFrame.decodedNs`)
/// expressed on the `CACurrentMediaTime` timeline the present path schedules against.
///
/// It reads the two clocks in the SAME ORDER as `realtimeNs(forDisplayLinkTimestamp:)` above
/// and forms the same difference, so the sub-microsecond skew between the two reads is the
/// same sign in both and cancels on a round trip.
///
/// The cadence loop needs this because its rule is one domain in, SAME domain out: it is fed
/// the decode-output instant in media time and its due time comes back in media time, with no
/// second conversion anywhere downstream. (A constant realtimemedia offset would be absorbed
/// by the loop's own offset estimator and need no conversion at all but the two clocks
/// diverge across device sleep, which is exactly why the conversion is done per frame here
/// rather than once per session.)
static func mediaTimeNs(forRealtimeNs t: Int64) -> Int64 {
let caNow = CACurrentMediaTime()
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let realtimeNow = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
return Int64(caNow * 1_000_000_000) + (t - realtimeNow)
}
/// Stamp a decoded frame with when it is DUE on the source's cadence, at the moment it enters
/// the ready store. Returns the frame untouched when the session has no clock (the latency
/// intent).
///
/// A frame whose wire pts did not survive (`ptsNs == 0` the decoder's "unknown" value) is
/// not on the source's timeline at all, so it is folded through `noteOffCadence`: due as soon
/// as it is ready, and the estimate left alone. Folding "now" in would drag the offset toward
/// this instant precisely when the loop has the least evidence.
private static func dated(
_ frame: ReadyFrame, by clock: CadenceClock?, hint: FrameRateHint
) -> ReadyFrame {
guard let clock else { return frame }
let readyNs = mediaTimeNs(forRealtimeNs: frame.decodedNs)
let interval = hint.sourceIntervalNs()
let dueNs =
frame.ptsNs > 0
? clock.dueNs(
srcPtsNs: frame.ptsNs, readyNs: readyNs, frameIntervalNs: interval)
: clock.noteOffCadence(readyNs: readyNs, frameIntervalNs: interval)
var dated = frame
dated.dueMediaTime = Double(dueNs) / 1_000_000_000
return dated
}
}
#endif
@@ -47,6 +47,12 @@ public struct ReadyFrame: @unchecked Sendable {
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
/// mark) at present time the async decode callback has no other access to it. 0 when unknown.
public let flags: UInt32
/// When this frame is DUE on the SOURCE's cadence, in `CACurrentMediaTime` seconds the
/// domain the present path schedules against (`presentAtMediaTime`, `VsyncClock`). Stamped
/// where the frame enters the ready store, by the pipeline's `CadenceClock`; `nil` under the
/// latency intent, which has no clock and presents on arrival. May be in the PAST: that is a
/// late frame, and the contract is "already due present at the next opportunity".
public var dueMediaTime: CFTimeInterval?
/// The VideoToolbox path's buffer; nil for a PyroWave planar frame. (Kept as the accessor
/// the decode round-trip tests assert against.)
@@ -719,5 +719,117 @@ final class AudioRingDriftTests: XCTestCase {
XCTAssertEqual(stats.bufferedMS, 30)
XCTAssertEqual(stats.avOffsetMS, 37, "positive = audio behind the picture")
}
// MARK: - Drought concealment (WP-C1)
/// Concealment is for a ring that is running OUT. A drought a deep ring can cover is
/// inaudible, and synthesizing over it would insert audio the late packets are about to
/// duplicate the stream would then run permanently later and the drift shed would have to
/// cut it back out, audibly.
///
/// Mirrors `a_drought_is_concealed_only_while_the_ring_is_running_out`.
func testADroughtIsConcealedOnlyWhileTheRingIsRunningOut() {
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
let stalledMS = 3 * AudioRing.frameMS
XCTAssertFalse(
c.conceal(sinceLastPacketMS: stalledMS, depthMS: 40),
"a 40 ms ring covers this drought by itself")
XCTAssertTrue(
c.conceal(sinceLastPacketMS: stalledMS, depthMS: 0), "an empty ring does not")
XCTAssertEqual(c.totalMS, AudioRing.frameMS)
}
/// Ordinary arrival jitter is not a drought this policy must be invisible until the wire has
/// genuinely stopped.
///
/// Mirrors `ordinary_jitter_is_not_a_drought`.
func testOrdinaryJitterIsNotADrought() {
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
for _ in 0..<1_000 {
XCTAssertFalse(c.conceal(sinceLastPacketMS: AudioRing.frameMS, depthMS: 0))
}
XCTAssertEqual(c.totalMS, 0)
}
/// The window is bounded, and bounded in TIME the whole reason `deprimeMS` stopped being a
/// callback count (`testDeprimeFuseIsADurationNotACallbackCount`). Derived from the fuse, so
/// it cannot drift away from the thing it protects: an edit to one is an edit to both.
///
/// Mirrors `drought_concealment_is_bounded_at_twice_the_deprime_fuse`.
func testDroughtConcealmentIsBoundedAtTwiceTheDeprimeFuse() {
let deprimeMS = 60 // AudioRing.deprimeMS / JitterTuning::COREAUDIO.deprime_ms
XCTAssertEqual(AudioRing.plcMaxMS, 2 * deprimeMS)
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
var ms = 0
for _ in 0..<1_000 where c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0) {
ms += AudioRing.frameMS
}
XCTAssertEqual(ms, AudioRing.plcMaxMS, "must use exactly the budget, and stop there")
XCTAssertEqual(c.totalMS, AudioRing.plcMaxMS, "and report every millisecond of it")
}
/// A packet ends the drought and hands back a full budget for the next one a link that
/// stalls once a minute must be covered every time, not only the first.
///
/// The other half of the Rust `concealment_already_paid_for_is_not_paid_for_twice` that
/// frames a drought already covered are subtracted from the loss concealment the seq path then
/// asks for cannot be tested from here: on this leg the gap tracker lives behind the C ABI,
/// and so does the subtraction (`drought_concealment_is_not_charged_again_by_the_loss_path` in
/// `punktfunk_core::abi`).
func testAPacketEndsTheDroughtAndRefreshesTheBudget() {
var c = DroughtConceal(maxMS: AudioRing.plcMaxMS)
for _ in 0..<1_000 where c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0) {}
XCTAssertEqual(c.totalMS, AudioRing.plcMaxMS, "budget spent")
XCTAssertFalse(c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0))
c.packet()
XCTAssertTrue(
c.conceal(sinceLastPacketMS: 2 * AudioRing.frameMS, depthMS: 0),
"the next drought must start from a full budget")
XCTAssertEqual(
c.totalMS, AudioRing.plcMaxMS + AudioRing.frameMS,
"the SESSION total keeps counting — it is what the log line reports")
}
/// THE field scenario this exists for, played against the real ring: the wire goes quiet for
/// longer than the de-prime fuse (a Wi-Fi delivery stall, or a host whose capture stalled).
/// Without concealment the ring drains, starves, and re-primes a whole target's worth of fresh
/// silence an artifact far longer than the audio that was missing. Fed one synthesized frame
/// per drain tick instead, playback continues through the whole budget and nobody hears the
/// stall at all.
func testConcealmentRidesOutAStallThatWouldOtherwiseDeprime() {
/// Prime, then stall the wire for `ms`, ticking the drain thread's 5 ms loop and the
/// device callback in step. Returns when the first silent callback lands (nil = none).
func stall(ms: Int, concealing: Bool) -> Int? {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 25 * perMS)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: 25 * perMS) }
var drought = DroughtConceal(maxMS: AudioRing.plcMaxMS)
for tick in 0..<(ms / AudioRing.frameMS) {
if concealing,
drought.conceal(
sinceLastPacketMS: tick * AudioRing.frameMS, depthMS: ring.bufferedMS) {
feed.withUnsafeBufferPointer {
ring.write($0.baseAddress!, count: AudioRing.frameMS * perMS)
}
}
scratch.withUnsafeMutableBufferPointer {
ring.read(into: $0.baseAddress!, count: want)
}
if scratch.allSatisfy({ $0 == 0 }) { return tick * AudioRing.frameMS }
}
return nil
}
// The defect: 25 ms of ring, a 60 ms fuse the stall is silent well inside the budget.
guard let deprimedAt = stall(ms: AudioRing.plcMaxMS, concealing: false) else {
return XCTFail("the unconcealed stall must still de-prime — the ring changed under us")
}
XCTAssertLessThan(deprimedAt, AudioRing.plcMaxMS)
XCTAssertNil(
stall(ms: AudioRing.plcMaxMS, concealing: true),
"a stall inside the budget must not reach the listener at all (unconcealed: silent "
+ "after \(deprimedAt) ms)")
}
}
#endif
@@ -0,0 +1,216 @@
import XCTest
#if canImport(Metal)
@testable import PunktfunkKit
/// Source-timestamp playout: the Swift `CadenceClock` against the SAME synthetic inputs its Rust
/// original runs (`punktfunk_core::phase::tests`) one test per Rust test, matching names, the
/// same constants, the same deterministic LCG. The port is hand-written because this pipeline does
/// not link the Rust type, so agreement on these vectors is the whole lockstep contract: a
/// constant or a rounding rule that drifts on either side fails here, not on a user's screen.
final class CadenceClockTests: XCTestCase {
/// 120 Hz in ns.
private static let p: Int64 = 8_333_333
/// A source stamping realtime, played out by a client whose present clock is monotonic and
/// therefore a whole different era. The loop must never need to be told about this.
private static let pts0: UInt64 = 1_786_000_000_000_000_000
private static let domain: Int64 = -1_785_000_000_000_000_000
/// Transport + decode: what `ready pts` sits at once the domain is taken out.
private static let delay: Int64 = 12_000_000
/// Deterministic LCG in ±spread around zero no OS randomness in tests. The multiplier,
/// increment and the `>> 33` fold are the Rust harness's, so both sides replay the identical
/// jitter sequence for a given seed.
private struct Lcg {
private var state: UInt64
init(_ seed: UInt64) { state = seed }
mutating func noise(_ spreadNs: Int64) -> Int64 {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
guard spreadNs != 0 else { return 0 }
return Int64(state >> 33) % (2 * spreadNs) - spreadNs
}
}
private static func ptsAt(_ k: Int64) -> UInt64 {
UInt64(bitPattern: Int64(pts0) + k * p)
}
/// Run `n` frames of a well-behaved 120 Hz source and hand back the clock.
private static func settled(_ n: Int64, spread: Int64) -> CadenceClock {
let c = CadenceClock(tuning: .snapping())
var rng = Lcg(7)
for k in 0..<n {
let ready = Int64(bitPattern: ptsAt(k)) + domain + delay + rng.noise(spread)
_ = c.dueNs(srcPtsNs: ptsAt(k), readyNs: ready, frameIntervalNs: p)
}
return c
}
func testSettlesFromCold() {
let c = Self.settled(400, spread: 1_000_000)
let err = c.health().offsetNs - (Self.domain + Self.delay)
XCTAssertLessThan(
abs(err), 500_000,
"offset must converge on the true transport delay, off by \(err) ns")
XCTAssertEqual(c.health().reanchors, 1, "only the cold start anchors")
}
/// The type-2 property, and the reason the loop carries a rate term at all: two free-running
/// crystals produce a RAMP, and a proportional-only loop lags a ramp forever. Asserted against
/// its own type-1 twin so the difference is the measurement, not a threshold anyone chose.
func testTracksAClockRamp() {
let ramp: Int64 = 400 // ns per frame 48 ppm, an ordinary crystal pair
func run(_ tuning: CadenceTuning) -> Int64 {
let c = CadenceClock(tuning: tuning)
var lastErr: Int64 = 0
for k in Int64(0)..<4_000 {
let pts = Int64(bitPattern: Self.ptsAt(k))
let ready = pts + Self.domain + Self.delay + k * ramp
_ = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
lastErr = (ready - pts) - c.health().offsetNs
}
return abs(lastErr)
}
let type2 = run(.snapping())
// The same loop with its integral gain switched off: a shift this large truncates every
// residual to zero, which is exactly "proportional only".
var type1Tuning = CadenceTuning.snapping()
type1Tuning.skewShift = 63
let type1 = run(type1Tuning)
XCTAssertLessThan(
type2 * 4, type1,
"a rate term must beat proportional-only on a ramp: \(type2) ns vs \(type1) ns")
XCTAssertLessThan(type2, 3_000, "steady-state ramp error \(type2) ns")
}
func testRejectsASingleOutlier() {
let c = Self.settled(400, spread: 200_000)
let before = c.health().offsetNs
// One frame arrives half a second late a stall, not a new operating point.
let k: Int64 = 400
_ = c.dueNs(
srcPtsNs: Self.ptsAt(k),
readyNs: Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + 500_000_000,
frameIntervalNs: Self.p)
let moved = abs(c.health().offsetNs - before)
// The clamped correction, plus the one frame of rate the loop advances by regardless
// that advance is the estimate doing its job, not the outlier moving it.
let t = CadenceTuning.snapping()
let bound = (t.errorClampNs >> t.offsetShift) + abs(c.health().skewNs)
XCTAssertLessThanOrEqual(
moved, bound, "one outlier moved the estimate \(moved) ns, past the clamp's \(bound)")
}
func testReanchorsOnAGap() {
let c = Self.settled(400, spread: 200_000)
let anchors = c.health().reanchors
// The stream was paused for two seconds; the estimate cannot have tracked across that.
let far = Self.ptsAt(400) + 2_000_000_000
let ready = Int64(bitPattern: far) + Self.domain + Self.delay + 4_000_000
_ = c.dueNs(srcPtsNs: far, readyNs: ready, frameIntervalNs: Self.p)
XCTAssertEqual(c.health().reanchors, anchors + 1)
XCTAssertEqual(
c.health().offsetNs, ready - Int64(bitPattern: far),
"a re-anchor adopts the new sample outright rather than slewing to it")
}
func testReanchorsOnRegression() {
let c = Self.settled(400, spread: 200_000)
let anchors = c.health().reanchors
let back = Self.ptsAt(200) // source timestamps went backwards
_ = c.dueNs(
srcPtsNs: back, readyNs: Int64(bitPattern: back) + Self.domain + Self.delay,
frameIntervalNs: Self.p)
XCTAssertEqual(c.health().reanchors, anchors + 1)
}
/// A due time in the past is returned AS IS. Clamping it to `readyNs` would quietly turn every
/// late frame into a fresh anchor, which is how an arrival-driven presenter behaves the
/// thing this clock exists to stop being.
func testLateFrameReturnsPastDue() {
let c = Self.settled(400, spread: 200_000)
let k: Int64 = 400
let ready = Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + 30_000_000
let due = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
XCTAssertLessThan(due, ready, "a frame that arrived 30 ms late must read as already due")
XCTAssertEqual(c.health().late, 1)
}
func testOffCadenceDoesNotMoveTheLoop() {
let c = Self.settled(400, spread: 500_000)
let before = c.health()
let due = c.noteOffCadence(readyNs: 1_000_000, frameIntervalNs: Self.p)
let after = c.health()
XCTAssertEqual(before.offsetNs, after.offsetNs)
XCTAssertEqual(before.skewNs, after.skewNs)
XCTAssertEqual(before.jitterNs, after.jitterNs)
XCTAssertEqual(before.frames, after.frames, "and it is not a cadence sample")
XCTAssertEqual(due, 1_000_000 + c.cushionNs())
}
/// One domain in, same domain out: shifting the whole present-side trace by an arbitrary
/// constant must change every due time by exactly that constant and nothing else. This is what
/// lets each client feed its own clock without a conversion in the path and on Apple it is
/// what makes `mediaTimeNs(forRealtimeNs:)` the ONE conversion in the whole loop.
func testDomainOffsetIsAbsorbed() {
let shift: Int64 = 987_654_321_000
func run(_ extra: Int64) -> [Int64] {
let c = CadenceClock(tuning: .snapping())
var rng = Lcg(11)
return (Int64(0)..<300).map { k in
let ready =
Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + extra
+ rng.noise(2_000_000)
return c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
}
}
let a = run(0)
let b = run(shift)
for (i, pair) in zip(a, b).enumerated() {
XCTAssertEqual(
pair.1 - pair.0, shift, "frame \(i) shifted by \(pair.1 - pair.0) not \(shift)")
}
}
/// The invariant that separates this from a metronome: a source that genuinely runs at an
/// irregular rate is REPRODUCED, not evened out. Anything that made these due spacings more
/// uniform than the source's own would be a bug.
func testPreservesSourceCadence() {
let c = CadenceClock(tuning: .snapping())
// A deliberately lumpy source: alternating short and long frames.
let spacings = (0..<300).map { $0 % 2 == 0 ? Self.p / 2 : Self.p * 3 / 2 }
var pts = Self.pts0
var dues: [Int64] = []
var ptss: [Int64] = []
var rng = Lcg(13)
for s in spacings {
pts = UInt64(bitPattern: Int64(bitPattern: pts) + s)
let ready = Int64(bitPattern: pts) + Self.domain + Self.delay + rng.noise(500_000)
ptss.append(Int64(bitPattern: pts))
dues.append(c.dueNs(srcPtsNs: pts, readyNs: ready, frameIntervalNs: Self.p))
}
// Compare the back half, once the loop has settled.
for i in 200..<dues.count {
let dDue = dues[i] - dues[i - 1]
let dPts = ptss[i] - ptss[i - 1]
XCTAssertLessThan(
abs(dDue - dPts), 200_000, "due spacing \(dDue) must follow the source's \(dPts)")
}
}
func testCushionRespectsCeiling() {
let c = CadenceClock(tuning: .freeRunning())
var rng = Lcg(17)
// Jitter far wider than a frame the cushion must still never exceed one interval.
for k in Int64(0)..<500 {
let ready =
Int64(bitPattern: Self.ptsAt(k)) + Self.domain + Self.delay + rng.noise(40_000_000)
_ = c.dueNs(srcPtsNs: Self.ptsAt(k), readyNs: ready, frameIntervalNs: Self.p)
XCTAssertLessThanOrEqual(
c.cushionNs(), Self.p, "cushion \(c.cushionNs()) ns exceeded the frame interval")
}
XCTAssertGreaterThan(c.jitterNs(), Self.p, "the harness must actually have stressed it")
}
}
#endif
+6
View File
@@ -564,6 +564,12 @@ pub type CursorForwardSender = std::sync::Arc<dyn Fn(bool) -> Result<()> + Send
#[cfg(target_os = "linux")]
pub mod pwinit;
// Which clock the wire's `pts_ns` comes from, and how clean each candidate is. Only the Linux
// capturer consumes it, but the arithmetic is pure and platform-independent, so its tests run
// everywhere rather than only where the backend does.
#[cfg(any(target_os = "linux", test))]
mod pts_provenance;
// The Windows backend lives under `windows/`, the Linux one under `linux/`. Windows capture is IDD
// direct-push only (DXGI Desktop Duplication + the WGC relay were removed).
#[cfg(target_os = "windows")]
+108 -13
View File
@@ -71,6 +71,19 @@ struct UserData {
linear_nv12_failed: bool,
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
dbg_log_n: u64,
/// WP-A3/B3 — which clock the wire's `pts_ns` comes from, and how clean each one is. See
/// [`crate::pts_provenance`]: the delivery stamp this loop used to take unconditionally is
/// downstream of the compositor's delivery jitter, so it bakes that jitter into the
/// timestamps the client plays back from.
pts: crate::pts_provenance::PtsProvenance,
/// When the provenance window opened.
pts_reported: std::time::Instant,
/// `CLOCK_REALTIME CLOCK_MONOTONIC`, ns — what carries the compositor's monotonic stamp
/// into the wire's realtime domain. Re-sampled each reporting window; the two clocks drift
/// by µs over 30 s, so this is not a per-frame cost.
rt_minus_mono_ns: i64,
/// `PUNKTFUNK_CAPTURE_HDR_PTS=0` puts the wire back on the delivery stamp unconditionally.
hdr_pts_enabled: bool,
/// PW4 step 1: the producer-fence wait distribution, measured on this (the PipeWire loop)
/// thread. Per-session, like the fall-through tally.
fence_wait: FenceWaitStats,
@@ -790,10 +803,40 @@ impl Drop for DmabufMap {
///
/// `pw_buf` is the buffer's `pw_buffer` handle (`spa_buf`'s owner), used only as the identity a
/// raw-passthrough publish withholds via [`UserData::try_defer`] — never dereferenced here.
/// How often the wire-pts provenance line is emitted (WP-A3). Matches the audio plane's stats
/// cadence so a field log reads as one timeline.
const PTS_REPORT_EVERY: std::time::Duration = std::time::Duration::from_secs(30);
/// `CLOCK_REALTIME CLOCK_MONOTONIC`, ns.
///
/// PipeWire stamps `spa_meta_header.pts` in the graph's clock domain (`CLOCK_MONOTONIC`); the wire
/// — and the client's plausibility gate — speak realtime-since-epoch. Sampling the pair back to
/// back is what carries one into the other, and the residual is the few hundred nanoseconds
/// between the two reads against a 50 ms plausibility window. A failed read reports 0, which puts
/// every rebased stamp outside that window and falls the whole stream back to delivery stamps —
/// the safe direction.
fn realtime_minus_monotonic_ns() -> i64 {
let rt = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: `clock_gettime` writes one `timespec` through the pointer and touches nothing else;
// `ts` is a live, properly aligned local. A non-zero return leaves it untouched.
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) } != 0 {
return 0;
}
rt - (ts.tv_sec * 1_000_000_000 + ts.tv_nsec)
}
fn consume_frame(
ud: &mut UserData,
spa_buf: *mut spa::sys::spa_buffer,
pw_buf: *mut pw::sys::pw_buffer,
hdr_pts_ns: Option<i64>,
) {
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.signals.active.load(Ordering::Relaxed) {
@@ -831,6 +874,54 @@ fn consume_frame(
return; // format not negotiated yet
}
// ONE stamp for this frame, whichever of the three paths below publishes it (WP-B3). Each
// used to take its own `SystemTime::now()` at the moment it happened to reach the publish, so
// a CPU de-pad's milliseconds landed INSIDE the timestamp and the three paths could drift
// apart without anything saying so. Sampled here, before any of that work, the stamp
// describes the frame's arrival rather than our handling of it.
let delivery_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let hdr_pts_ns = hdr_pts_ns.filter(|&p| p > 0);
// Measured on EVERY capture, whether or not the compositor's stamp is the one we ship: the
// question "which clock is cleaner on this host" is what decides that, and a diagnostic that
// only runs once you already trust the answer cannot inform it.
ud.pts.observe(hdr_pts_ns, delivery_ns);
let stamp = crate::pts_provenance::wire_pts(
ud.hdr_pts_enabled.then_some(hdr_pts_ns).flatten(),
delivery_ns,
ud.rt_minus_mono_ns,
);
let pts_ns = stamp.pts_ns;
if ud.hdr_pts_enabled && hdr_pts_ns.is_some() && !stamp.from_header {
ud.pts.implausible += 1;
}
if ud.pts_reported.elapsed() >= PTS_REPORT_EVERY {
if let Some(r) = ud.pts.report() {
tracing::info!(
frames = r.frames,
with_hdr = r.with_hdr,
samples = r.samples,
period_us = r.period_us,
// THE pair this whole work package exists to compare. Materially tighter on the
// left ⇒ the compositor's stamp is worth shipping; equally ragged ⇒ the producer
// composes irregularly and no choice of stamp can fix it.
hdr_mad_us = r.hdr_mad_us,
delivery_mad_us = r.delivery_mad_us,
offset_p50_ms = r.offset_p50_ms,
implausible = r.implausible,
hdr_pts_used = ud.hdr_pts_enabled,
"capture wire-pts provenance"
);
}
ud.pts.reset_window();
ud.pts_reported = std::time::Instant::now();
// The two clocks drift by µs over a window; re-pairing them here keeps the rebase honest
// over a multi-hour session without costing anything per frame.
ud.rt_minus_mono_ns = realtime_minus_monotonic_ns();
}
// Implicit-fence wait: Mutter renders into the dmabuf and hands it over at
// GPU-submit time; with no producer explicit sync (Mutter+NVIDIA can't) we snapshot
// the buffer's implicit fence and wait the producer's render before sampling —
@@ -985,10 +1076,6 @@ fn consume_frame(
if dup < 0 {
break 'passthrough PassthroughFallback::DupFailed;
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let hold = ud.try_defer(pw_buf);
ud.publish(CapturedFrame {
width: w as u32,
@@ -1134,10 +1221,6 @@ fn consume_frame(
"zero-copy: dmabuf imported to CUDA (no CPU copy)"
);
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
ud.publish(CapturedFrame {
width: w as u32,
height: h as u32,
@@ -1342,10 +1425,6 @@ fn consume_frame(
// the layout isn't packed RGB). This is the CPU path's counterpart to the producer's
// hardware cursor plane, which stays out of the captured buffer.
composite_cursor(&mut tight, w, h, fmt, &ud.cursor);
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let frame = CapturedFrame {
width: w as u32,
height: h as u32,
@@ -1633,6 +1712,10 @@ pub fn pipewire_thread(
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
pts: crate::pts_provenance::PtsProvenance::new(),
pts_reported: std::time::Instant::now(),
rt_minus_mono_ns: realtime_minus_monotonic_ns(),
hdr_pts_enabled: std::env::var("PUNKTFUNK_CAPTURE_HDR_PTS").as_deref() != Ok("0"),
fence_wait: FenceWaitStats::default(),
pool: PoolCensus::default(),
passthrough_fallbacks: PassthroughFallbacks::default(),
@@ -1912,6 +1995,18 @@ pub fn pipewire_thread(
// while the buffer is still held.
unsafe { (*hdr).flags }
};
// The compositor's OWN stamp for this frame (WP-A3/B3), stamped upstream of the
// delivery jitter our `SystemTime::now()` cannot see past. Located here because
// the header already is; whether it is worth shipping is what the provenance
// line measures.
let hdr_pts = if hdr.is_null() {
None
} else {
// SAFETY: as for `.flags` — non-null, from a lookup that demanded at least
// `size_of::<spa_meta_header>()` bytes (so `.pts` is in bounds), read while
// the buffer is still held.
Some(unsafe { (*hdr).pts })
};
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
// stale skip only applies to mappable SHM buffers).
@@ -1960,7 +2055,7 @@ pub fn pipewire_thread(
return;
}
consume_frame(ud, spa_buf, newest);
consume_frame(ud, spa_buf, newest, hdr_pts);
}));
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
// or a caught panic in the closure above — UNLESS a raw-passthrough publish withheld it
+391
View File
@@ -0,0 +1,391 @@
//! Where the wire's presentation timestamp actually comes from
//! (design/host-source-stutter-fixes.md, WP-A3 and WP-B3).
//!
//! Every Linux capture publish stamps `pts_ns` with `SystemTime::now()` inside OUR PipeWire
//! process callback — the instant the buffer was DELIVERED to us, not the instant the compositor
//! produced it. On a host whose screencast delivery is jittery, that difference IS the jitter, and
//! it is baked into the timestamps the client eventually plays back from. (The 2026-08-15 Skynet
//! log: 41 phase-lock disengage cycles in 24 minutes, arrival offsets up to a full 120 Hz period,
//! on a session whose transport was provably clean.) The compositor's own `spa_meta_header.pts` is
//! stamped upstream of that delivery, so it *might* be clean — but "might" is the entire question,
//! and the client-side cure (source-timestamp playout) faithfully REPRODUCES whatever jitter the
//! timestamps carry rather than absorbing it. So: measure both clocks in the same window, against
//! each other, before trusting either.
//!
//! Time is passed IN rather than read here, which keeps this pure and lets its tests run on every
//! platform — the same reason `capture_policy` in the host crate is split out.
/// Frames sampled per reporting window. ~34 s at 120 Hz, so a 30 s window is covered without the
/// callback ever reallocating: the vectors are built once at their cap and only ever pushed into
/// while short of it. Allocation on the PipeWire loop thread is what this avoids.
const MAX_SAMPLES: usize = 4096;
/// A rebased compositor stamp further than this from the delivery instant is not a timestamp for
/// this frame: the wrong clock domain, a stale header, or a producer that never fills it in. Fall
/// back to the delivery stamp for that frame, and count it — silently trusting a garbage stamp
/// would put the whole stream's timing on a fiction (risk R3).
const PLAUSIBLE_NS: i64 = 50_000_000;
/// One frame's stamp, and which clock produced it.
pub(crate) struct WirePts {
pub(crate) pts_ns: u64,
/// False when this frame fell back to the delivery stamp — the honest per-frame answer, and
/// the number that says whether B3 is actually doing anything on this host.
pub(crate) from_header: bool,
}
/// The wire stamp for one frame.
///
/// `hdr_pts_ns` is the compositor's `spa_meta_header.pts`, which PipeWire defines in the graph's
/// clock domain (`CLOCK_MONOTONIC`); `delivery_ns` is realtime-since-epoch, which is the domain
/// the wire and the client's plausibility gate both speak. `rt_minus_mono_ns` carries one into the
/// other. A missing or implausible header stamp yields the delivery stamp — today's behaviour —
/// so a producer that fills in no header is unaffected.
pub(crate) fn wire_pts(
hdr_pts_ns: Option<i64>,
delivery_ns: u64,
rt_minus_mono_ns: i64,
) -> WirePts {
let fallback = WirePts {
pts_ns: delivery_ns,
from_header: false,
};
// Producers that have no timestamp write 0 (or leave it negative); neither is a stamp.
let Some(hdr) = hdr_pts_ns.filter(|&p| p > 0) else {
return fallback;
};
let rebased = hdr.saturating_add(rt_minus_mono_ns);
if rebased <= 0 || (rebased - delivery_ns as i64).abs() >= PLAUSIBLE_NS {
return fallback;
}
WirePts {
pts_ns: rebased as u64,
from_header: true,
}
}
/// One reporting window of "which clock is cleaner", plus the domain sanity check.
#[derive(Default)]
pub(crate) struct PtsProvenance {
frames: u64,
with_hdr: u64,
/// Intervals between consecutive stamps, ns — one series per clock. THE pair the whole WP
/// exists to compare: if the compositor's is materially tighter than ours, its stamp is worth
/// adopting; if both are equally ragged, the compositor is composing irregularly and no
/// choice of stamp can fix it (risk R7).
hdr_intervals: Vec<i64>,
delivery_intervals: Vec<i64>,
/// `hdr delivery` per frame. Expected to be huge and roughly CONSTANT (two clock origins);
/// its variance is the signal, and a wildly varying one means the header is not a per-frame
/// stamp at all.
offsets: Vec<i64>,
prev_hdr: Option<i64>,
prev_delivery: Option<u64>,
/// Frames that asked for the header stamp and were refused by the plausibility gate.
pub(crate) implausible: u64,
}
/// A window's worth, in the units a log line wants.
pub(crate) struct PtsReport {
pub(crate) frames: u64,
pub(crate) with_hdr: u64,
/// Intervals the deviations were computed from. A MAD over eight samples and one over three
/// thousand deserve different amounts of belief, and the log line should not hide which it is.
pub(crate) samples: u64,
/// Median interval between deliveries — the empirical period. Derived rather than taken from
/// the negotiated refresh on purpose: a median is immune both to a wrong nominal and to the
/// occasional skipped tick, and a skipped tick is exactly what a fixed nominal would
/// mis-score as jitter.
pub(crate) period_us: i64,
/// Median absolute deviation of each clock's intervals about ITS OWN median — "how ragged is
/// this clock's cadence", and nothing else. Judging both against one shared centre sounds
/// tidier and is worse: it folds the period-estimation error, and any genuine rate difference
/// between two clock sources, into a number that is supposed to be about jitter. A perfectly
/// regular producer would then report several µs of dispersion it does not have.
pub(crate) hdr_mad_us: i64,
pub(crate) delivery_mad_us: i64,
pub(crate) offset_p50_ms: i64,
pub(crate) implausible: u64,
}
impl PtsProvenance {
pub(crate) fn new() -> PtsProvenance {
PtsProvenance {
hdr_intervals: Vec::with_capacity(MAX_SAMPLES),
delivery_intervals: Vec::with_capacity(MAX_SAMPLES),
offsets: Vec::with_capacity(MAX_SAMPLES),
..Default::default()
}
}
/// Fold one frame in. `hdr_pts_ns` is `None` when the buffer carried no usable header, which
/// also breaks the header interval chain — an interval spanning a frame we could not stamp
/// would read as one clean long gap rather than the missing measurement it is.
pub(crate) fn observe(&mut self, hdr_pts_ns: Option<i64>, delivery_ns: u64) {
self.frames += 1;
if let Some(prev) = self.prev_delivery {
push_capped(
&mut self.delivery_intervals,
delivery_ns as i64 - prev as i64,
);
}
self.prev_delivery = Some(delivery_ns);
let Some(hdr) = hdr_pts_ns.filter(|&p| p > 0) else {
self.prev_hdr = None;
return;
};
self.with_hdr += 1;
push_capped(&mut self.offsets, hdr - delivery_ns as i64);
if let Some(prev) = self.prev_hdr {
push_capped(&mut self.hdr_intervals, hdr - prev);
}
self.prev_hdr = Some(hdr);
}
/// The window's answer, or `None` if too little arrived to say anything.
pub(crate) fn report(&mut self) -> Option<PtsReport> {
if self.delivery_intervals.len() < 8 {
return None;
}
Some(PtsReport {
frames: self.frames,
with_hdr: self.with_hdr,
samples: self.delivery_intervals.len() as u64,
period_us: median(&mut self.delivery_intervals) / 1_000,
hdr_mad_us: mad(&mut self.hdr_intervals) / 1_000,
delivery_mad_us: mad(&mut self.delivery_intervals) / 1_000,
offset_p50_ms: median(&mut self.offsets) / 1_000_000,
implausible: self.implausible,
})
}
/// Start a fresh window. The previous stamps survive so the first interval of the new window
/// is a real measurement rather than a hole.
pub(crate) fn reset_window(&mut self) {
self.frames = 0;
self.with_hdr = 0;
self.implausible = 0;
self.hdr_intervals.clear();
self.delivery_intervals.clear();
self.offsets.clear();
}
}
fn push_capped(v: &mut Vec<i64>, x: i64) {
if v.len() < MAX_SAMPLES {
v.push(x);
}
}
/// Median, in place. Empty reports 0 so a log line stays parseable (the caller has already
/// declined to report on a window this thin).
fn median(v: &mut [i64]) -> i64 {
if v.is_empty() {
return 0;
}
v.sort_unstable();
v[v.len() / 2]
}
/// Median absolute deviation about the series' own median — robust to the occasional skipped
/// tick, which a mean would let dominate and a fixed nominal would mis-score as jitter.
fn mad(v: &mut [i64]) -> i64 {
if v.is_empty() {
return 0;
}
let centre = median(v);
for x in v.iter_mut() {
*x = (*x - centre).abs();
}
median(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// A monotonic clock a few days into an uptime, against a realtime clock in 2026 — the domain
/// gap the rebase exists to close, and the shape the plausibility gate must not mistake for a
/// bad stamp.
const MONO_BASE: i64 = 300_000 * 1_000_000_000;
const RT_BASE: u64 = 1_786_000_000 * 1_000_000_000;
const RT_MINUS_MONO: i64 = RT_BASE as i64 - MONO_BASE;
#[test]
fn a_rebased_compositor_stamp_is_used_and_a_stale_one_is_not() {
// In domain and on time: adopted.
let w = wire_pts(
Some(MONO_BASE + 1_000_000),
RT_BASE + 1_200_000,
RT_MINUS_MONO,
);
assert!(w.from_header);
assert_eq!(
w.pts_ns,
RT_BASE + 1_000_000,
"the compositor's own instant"
);
// Half a second stale — a header nobody refreshed. Fall back rather than put the stream's
// timing on a fiction.
let w = wire_pts(Some(MONO_BASE - 500_000_000), RT_BASE, RT_MINUS_MONO);
assert!(!w.from_header);
assert_eq!(w.pts_ns, RT_BASE);
// Never stamped at all (0), and the no-header case: today's behaviour, unchanged.
assert!(!wire_pts(Some(0), RT_BASE, RT_MINUS_MONO).from_header);
assert_eq!(wire_pts(None, RT_BASE, RT_MINUS_MONO).pts_ns, RT_BASE);
}
/// The wrong clock domain is the failure mode risk R3 names, and it must be *loud in the
/// numbers and silent in the stream*: every frame falls back, nothing is corrupted.
#[test]
fn a_raw_monotonic_stamp_never_reaches_the_wire() {
let w = wire_pts(Some(MONO_BASE), RT_BASE, 0); // rebase forgotten
assert!(!w.from_header);
assert_eq!(w.pts_ns, RT_BASE);
}
const PERIOD: i64 = 8_333_333; // 120 Hz
/// Deterministic LCG in ±spread around zero (no OS randomness in tests). Zero-mean noise, not
/// a short repeating cycle: a cycle's interval series has only a handful of distinct values
/// and its median lands on one of the jitter peaks rather than on the period — which is a
/// property of that harness, not of the statistic.
struct Lcg(u64);
impl Lcg {
fn noise(&mut self, spread_ns: i64) -> i64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((self.0 >> 33) as i64 % (2 * spread_ns)) - spread_ns
}
}
/// The measurement the branch decision rests on: a compositor stamping a clean 120 Hz grid
/// whose buffers reach us raggedly must show a materially tighter MAD than our delivery
/// stamps. This is the field shape — arrival offsets wandering up to a full period.
#[test]
fn a_clean_producer_behind_a_jittery_delivery_is_visible() {
let mut p = PtsProvenance::new();
let mut rng = Lcg(7);
for i in 0..600i64 {
let hdr = MONO_BASE + i * PERIOD; // the producer's own stamp is exact
let delivery = (RT_BASE as i64 + i * PERIOD + rng.noise(3_000_000)) as u64;
p.observe(Some(hdr), delivery);
}
let r = p.report().expect("600 frames is plenty");
assert_eq!(r.frames, 600);
assert_eq!(r.with_hdr, 600);
assert!(
(r.period_us - PERIOD / 1_000).abs() <= 100,
"the empirical period must find the grid, got {} us",
r.period_us
);
assert_eq!(r.hdr_mad_us, 0, "an exact producer has no deviation");
assert!(
r.delivery_mad_us > 1_000,
"delivery jitter of milliseconds must show as milliseconds, got {} us",
r.delivery_mad_us
);
// The domain check: a roughly CONSTANT offset is what "two clock origins" looks like — a
// varying one would mean the header is not a per-frame stamp at all. (The tolerance is
// the delivery jitter itself, which the offset carries by construction.)
let origins_ms = (MONO_BASE - RT_BASE as i64) / 1_000_000;
assert!(
(r.offset_p50_ms - origins_ms).abs() <= 5,
"offset p50 {} vs clock origins {origins_ms}",
r.offset_p50_ms
);
}
/// Risk R7 asserted: if the compositor composes irregularly rather than merely delivering
/// late, both clocks are equally ragged and the numbers say so — no stamp swap can help, and
/// the report must not flatter the header into looking like a cure.
#[test]
fn an_irregular_producer_is_not_flattered() {
let mut p = PtsProvenance::new();
let mut rng = Lcg(11);
for i in 0..600i64 {
// One wobble, carried faithfully by both clocks: the compositor really did compose
// at that instant, and really did deliver it straight away.
let wobble = rng.noise(3_000_000);
p.observe(
Some(MONO_BASE + i * PERIOD + wobble),
(RT_BASE as i64 + i * PERIOD + wobble) as u64,
);
}
let r = p.report().unwrap();
assert_eq!(
r.hdr_mad_us, r.delivery_mad_us,
"an irregular producer must look exactly as bad through either clock"
);
assert!(
r.hdr_mad_us > 1_000,
"…and both must show the wobble, got {} us",
r.hdr_mad_us
);
}
/// A producer that fills in no header at all still gets its delivery clock measured, and the
/// header series must not invent intervals across the frames it could not stamp.
#[test]
fn a_producer_without_headers_still_reports_its_delivery_clock() {
let mut p = PtsProvenance::new();
for i in 0..40i64 {
p.observe(None, (RT_BASE as i64 + i * 8_333_333) as u64);
}
let r = p.report().unwrap();
assert_eq!(r.with_hdr, 0);
assert_eq!(r.hdr_mad_us, 0, "no samples, not a clean clock");
assert!((r.period_us - 8_333).abs() <= 1);
}
/// A new window starts clean but NOT blind: the previous stamps survive the reset, so the
/// first interval after a report is a real measurement rather than a hole. Getting this wrong
/// silently drops one frame's interval every 30 s — invisible, and exactly the kind of slow
/// bias that makes two clocks look more alike than they are.
#[test]
fn a_reset_window_keeps_measuring_across_the_boundary() {
let mut p = PtsProvenance::new();
let mut rng = Lcg(13);
for i in 0..40i64 {
p.observe(
Some(MONO_BASE + i * PERIOD),
(RT_BASE as i64 + i * PERIOD) as u64,
);
}
p.implausible = 3;
let first = p.report().unwrap();
assert_eq!(
first.implausible, 3,
"the window's fallbacks must be reported"
);
p.reset_window();
for i in 40..80i64 {
let delivery = (RT_BASE as i64 + i * PERIOD + rng.noise(2_000_000)) as u64;
p.observe(Some(MONO_BASE + i * PERIOD), delivery);
}
let second = p.report().unwrap();
assert_eq!(second.frames, 40, "counts start over");
assert_eq!(second.implausible, 0, "…and so do the fallbacks");
assert_eq!(
second.samples, 40,
"40 frames across a boundary yield 40 intervals, not 39 — the chain survived"
);
assert_eq!(second.hdr_mad_us, 0, "the exact producer is still exact");
}
/// A window too thin to mean anything says nothing rather than reporting noise as a verdict.
#[test]
fn a_thin_window_reports_nothing() {
let mut p = PtsProvenance::new();
for i in 0..4i64 {
p.observe(Some(MONO_BASE + i), RT_BASE + i as u64);
}
assert!(p.report().is_none());
}
}
+11 -4
View File
@@ -174,6 +174,12 @@ impl Drop for AudioPlayer {
}
}
/// This backend's de-jitter tuning. Named once so the decode thread can read the same numbers the
/// callback runs on — its drought concealment is bounded by this preset's de-prime fuse, and the
/// two drifting apart is exactly how one platform quietly ends up with a third of another's slack.
pub(crate) const TUNING: punktfunk_core::audio::JitterTuning =
punktfunk_core::audio::JitterTuning::PIPEWIRE;
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
struct PlayerData {
rx: Receiver<Vec<f32>>,
@@ -247,10 +253,7 @@ fn pw_thread(
rx: pcm_rx,
recycle: recycle_tx,
ring: VecDeque::new(),
policy: punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::PIPEWIRE,
channels as u8,
),
policy: punktfunk_core::audio::JitterPolicy::new(TUNING, channels as u8),
channels,
underruns: 0,
sheds: 0,
@@ -361,6 +364,10 @@ fn pw_thread(
target_ms = ud.policy.target_ms(),
underruns = ud.underruns,
drift_sheds = ud.sheds,
// Concealment must be visible next to the underruns it prevented: a
// healthy `underruns` bought with a climbing `plc_ms` is a link in
// trouble, not a link that is fine.
plc_ms = ud.sync.plc_ms(),
"audio playback"
);
}
+12 -4
View File
@@ -41,6 +41,13 @@ const CAPT_CHANNELS: usize = 2;
/// halves the frame-fill share of mouth-to-ear latency vs the old 20 ms.
const MIC_FRAME: usize = 480;
/// This backend's de-jitter tuning. Named once so the decode thread can read the same numbers the
/// render loop runs on — its drought concealment is bounded by this preset's de-prime fuse, and
/// the two drifting apart is exactly how one platform quietly ends up with a third of another's
/// slack.
pub(crate) const TUNING: punktfunk_core::audio::JitterTuning =
punktfunk_core::audio::JitterTuning::WASAPI;
/// A selectable WASAPI endpoint for the settings pickers.
#[derive(Clone, Debug)]
pub struct AudioDevice {
@@ -305,10 +312,7 @@ fn render_thread(
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
// target's worth of fresh silence.
let mut policy = punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::WASAPI,
channels,
);
let mut policy = punktfunk_core::audio::JitterPolicy::new(TUNING, channels);
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
@@ -367,6 +371,10 @@ fn render_thread(
target_ms = policy.target_ms(),
underruns,
drift_sheds = sheds,
// Concealment must be visible next to the underruns it prevented: a healthy
// `underruns` bought with a climbing `plc_ms` is a link in trouble, not a
// link that is fine.
plc_ms = sync.plc_ms(),
"audio playback"
);
}
+46 -3
View File
@@ -1686,8 +1686,25 @@ fn spawn_audio(
if !av_sync_enabled {
tracing::info!("A/V sync disabled by PUNKTFUNK_NO_AV_SYNC");
}
// WP-C1 — the drought half of concealment. The loop below already conceals a SEQ GAP,
// but only when a later packet arrives to reveal it; when the wire simply goes quiet
// nothing arrives to reveal anything, and the ring drains into an underrun and a
// de-prime whose re-prime is a longer artifact than the audio that was missing.
let mut drought =
punktfunk_core::audio::DroughtConceal::new(audio::TUNING.plc_max_ms());
let mut last_packet = std::time::Instant::now();
while !stop.load(Ordering::SeqCst) {
match connector.next_audio(Duration::from_millis(100)) {
// Wait at most one frame WHILE there is a stream to protect: the drought decision
// has to be made on the wire's schedule, not whenever the next packet happens to
// turn up. Before anything has decoded there is no state to conceal from and
// nothing to conceal for, so a session whose host never sends audio keeps the old
// long timeout rather than waking two hundred times a second to do nothing.
let wait_ms = if frame_samples > 0 {
punktfunk_core::audio::FRAME_MS as u64
} else {
100
};
match connector.next_audio(Duration::from_millis(wait_ms)) {
Ok(pkt) => {
// Place this frame against the picture it belongs with, BEFORE it is
// queued: `buffered_ahead` is everything that must still play first, so
@@ -1710,10 +1727,15 @@ fn spawn_audio(
sync_cell.set_target(av.desired_depth(depth));
av_offset_out.store(av.offset_ms() as i64, Ordering::Relaxed);
}
last_packet = std::time::Instant::now();
// Anything the drought path already covered is audio the stream now has;
// concealing it a second time here would insert samples it never carried
// and push everything after them later.
let already = drought.packet();
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one
// that arrived: empty input synthesizes `frame_samples` of interpolation per
// missing packet — an inaudible fade instead of the click a hard gap makes.
for _ in 0..gaps.missing_before(pkt.seq) {
for _ in 0..gaps.missing_before(pkt.seq).saturating_sub(already) {
let plc = frame_samples * channels as usize;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
@@ -1736,7 +1758,28 @@ fn spawn_audio(
Err(e) => tracing::debug!(error = %e, "opus decode failed"),
}
}
Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::NoFrame) => {
// Nothing on the wire. If the ring is draining with it, conceal from the
// decoder's own state — the same libopus interpolation the loss path uses,
// bounded by this backend's de-prime fuse so a genuinely dead stream is
// not papered over. `frame_samples` is 0 until something has decoded:
// there is no state to extrapolate from before then.
//
// ONE frame per tick, not a burst: this arm fires every `FRAME_MS`, which
// is exactly the rate the callback drains at, so concealment keeps pace
// with playout instead of racing ahead of a depth reading it has already
// invalidated.
let depth_ms = (sync_cell.depth() / per_ms) as u32;
if frame_samples > 0 && drought.conceal(last_packet.elapsed(), depth_ms) {
let plc = frame_samples * channels as usize;
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..samples * channels as usize]);
player.push(buf);
}
sync_cell.publish_plc_ms(drought.total_ms());
}
}
Err(_) => break, // plane closed — the session is ending
}
}
+311 -35
View File
@@ -12,11 +12,18 @@
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
//! queue and never needs it.
//! * [`SourcePacer`] — the shared [`punktfunk_core::phase::CadenceClock`] bound to this
//! client: under the smoothness intent frames are played out on the SOURCE's cadence
//! instead of on their arrival instant, so a raggedly-delivering host stops landing its
//! jitter on the glass 1:1.
//!
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
//! owns all clocks and Vulkan calls, which is what keeps this testable.
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in)
//! `DecodedFrame::decoded_ns` included, which is what lets the cadence clock run with no
//! domain conversion anywhere in this path. The run loop owns all clocks and Vulkan calls,
//! which is what keeps this testable.
use punktfunk_core::phase::{CadenceClock, CadenceHealth, CadenceTuning};
use std::collections::VecDeque;
/// Stale-present force-open: an undisplayed present older than this is presumed lost
@@ -64,10 +71,6 @@ impl<T> FrameStore<T> {
self.capacity > 0
}
pub(crate) fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub(crate) fn submit(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.pop_front().is_some() {
@@ -85,7 +88,13 @@ impl<T> FrameStore<T> {
}
}
pub(crate) fn take(&mut self) -> Option<T> {
/// The frame this pass will present, if any.
///
/// `due` answers "has this frame's due time arrived?" for the front of a smoothing
/// FIFO. Newest-wins never asks it — under the latency intent a frame is due the
/// instant it exists — which is what keeps the cadence clock out of that path
/// entirely.
pub(crate) fn take(&mut self, due: impl FnOnce(&T) -> bool) -> Option<T> {
if self.capacity == 0 {
return self.frames.pop_front();
}
@@ -97,14 +106,24 @@ impl<T> FrameStore<T> {
}
self.prerolled = true;
}
match self.frames.pop_front() {
Some(f) => Some(f),
None => {
self.underflows += 1;
self.prerolled = false;
None
}
let Some(f) = self.frames.front() else {
self.underflows += 1;
self.prerolled = false;
return None;
};
// Not yet due is the smoothing intent WORKING: the store has a frame and is
// holding it for its slot. No counter moves and the preroll stands — reading this
// as a dry buffer would re-arm the preroll on every well-paced frame.
if !due(f) {
return None;
}
self.frames.pop_front()
}
/// The frame `take` would consider next, without consuming it — the run loop sizes its
/// event-wait from that frame's due time.
pub(crate) fn front(&self) -> Option<&T> {
self.frames.front()
}
/// A frame taken but not presented (gate closed, present failed before consuming
@@ -403,6 +422,97 @@ impl CadenceProbe {
}
}
/// Plays frames out on the SOURCE's cadence: the shared
/// [`CadenceClock`](punktfunk_core::phase::CadenceClock) plus the two policy calls that
/// belong to this client rather than to the loop — which intent it applies to, and which
/// cushion the panel's measured refresh behaviour asks for.
///
/// The defect it exists for is a host that delivers raggedly. The 2026-08-15 Skynet trace
/// has KWin's screencast arriving 0.11-8.22 ms off its own grid — up to a full 120 Hz
/// period — for 24 minutes, with the bitrate pinned and zero packet loss; presented on
/// arrival, every one of those milliseconds lands on the glass.
///
/// The invariant to hold onto when touching this: the loop smooths the OFFSET, never the
/// timestamps. Genuine variation in the source's own cadence passes straight through to the
/// due time, so anything that made the due times more evenly spaced than the source would be
/// a bug and not an improvement (design/presenter-cadence-rework-implementation-plan.md
/// §2.2).
pub(crate) struct SourcePacer {
clock: CadenceClock,
/// Running the free-running tuning — i.e. the last verdict [`follow`](Self::follow)
/// saw was [`Cadence::Variable`].
free_running: bool,
}
impl SourcePacer {
pub(crate) fn new() -> SourcePacer {
SourcePacer {
clock: CadenceClock::new(CadenceTuning::snapping()),
free_running: false,
}
}
/// Fold a frame arriving at the store and answer when it is due, in the same clock
/// domain `ready_ns` came in.
///
/// `None` under the latency intent, which is arrival-driven by definition: it costs
/// what it always did, and the loop never carries an estimate built from samples it
/// then ignored.
///
/// Called at SUBMIT rather than at take, so the estimate sees the arrival process the
/// transport actually produced — the frames the store goes on to drop are part of it,
/// and folding what survived the store would hide exactly the jitter being measured.
pub(crate) fn due_ns(
&mut self,
smoothing: bool,
src_pts_ns: u64,
ready_ns: u64,
frame_interval_ns: i64,
) -> Option<i64> {
smoothing.then(|| {
self.clock
.due_ns(src_pts_ns, ready_ns as i64, frame_interval_ns)
})
}
/// Follow the measured refresh verdict. Snapping a due time onto the latch grid carries
/// roughly half a refresh of implicit slack, presenting at it directly carries none, so
/// the two want different cushions.
///
/// Re-tuning costs a re-anchor (the tuning is fixed at construction), which is why this
/// is keyed to the probe's PUBLISHED verdict — agreed across rounds — and not to a
/// per-window reading that was measured flapping on glass.
pub(crate) fn follow(&mut self, verdict: Cadence) {
let free = verdict == Cadence::Variable;
if free != self.free_running {
self.free_running = free;
self.clock = CadenceClock::new(if free {
CadenceTuning::free_running()
} else {
CadenceTuning::snapping()
});
}
}
/// Present at the due time itself instead of snapping it to the latch grid. True only
/// where variable refresh is MEASURED live, which is the one case where the panel
/// refreshes when we present and there is no grid to aim at.
pub(crate) fn free_running(&self) -> bool {
self.free_running
}
/// Re-anchor on the next frame — every discontinuity this loop already knows about (a
/// display change, an accepted mid-session mode switch). The measured jitter survives
/// it by design: it describes the link, not the stream.
pub(crate) fn reset(&mut self) {
self.clock.reset();
}
pub(crate) fn health(&self) -> CadenceHealth {
self.clock.health()
}
}
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
@@ -455,24 +565,24 @@ mod tests {
fn newest_wins_replaces_and_putback_never_clobbers() {
let mut s: FrameStore<u32> = FrameStore::new(0);
assert!(!s.is_smoothing());
assert_eq!(s.take(), None);
assert_eq!(s.take(|_| true), None);
s.submit(1);
s.submit(2);
s.submit(3);
assert_eq!(s.take(), Some(3), "only the newest survives");
assert_eq!(s.take(), None);
assert_eq!(s.take(|_| true), Some(3), "only the newest survives");
assert_eq!(s.take(|_| true), None);
// A taken-but-unpresented frame returns — unless a fresher one arrived.
s.submit(4);
let f = s.take().unwrap();
let f = s.take(|_| true).unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(4));
let f = s.take();
assert_eq!(s.take(|_| true), Some(4));
let f = s.take(|_| true);
assert_eq!(f, None);
s.submit(5);
let f = s.take().unwrap();
let f = s.take(|_| true).unwrap();
s.submit(6);
s.put_back(f); // 6 arrived while 5 was out — 6 wins
assert_eq!(s.take(), Some(6));
assert_eq!(s.take(|_| true), Some(6));
assert_eq!(
s.take_counters(),
(2, 0, 0),
@@ -486,26 +596,30 @@ mod tests {
let mut s: FrameStore<u32> = FrameStore::new(2);
assert!(s.is_smoothing());
s.submit(1);
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
s.submit(2);
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
assert_eq!(
s.take(),
s.take(|_| true),
None,
"prerolling: below capacity, nothing vends"
);
s.submit(2);
assert_eq!(s.take(|_| true), Some(1), "preroll reached — FIFO order");
assert_eq!(
s.take(|_| true),
Some(2),
"once prerolled the buffer drains normally"
);
// Dry after preroll = one underflow, preroll re-arms.
assert_eq!(s.take(), None);
assert_eq!(s.take(|_| true), None);
s.submit(3);
assert_eq!(s.take(), None, "re-armed preroll holds again");
assert_eq!(s.take(|_| true), None, "re-armed preroll holds again");
s.submit(4);
assert_eq!(s.take(), Some(3));
assert_eq!(s.take(|_| true), Some(3));
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
s.submit(5);
s.submit(6);
s.submit(7);
assert_eq!(s.take(), Some(6));
assert_eq!(s.take(), Some(7));
assert_eq!(s.take(|_| true), Some(6));
assert_eq!(s.take(|_| true), Some(7));
let (replaced, drops, dry) = s.take_counters();
assert_eq!(replaced, 0);
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
@@ -519,9 +633,57 @@ mod tests {
let mut s: FrameStore<u32> = FrameStore::new(2);
s.submit(1);
s.submit(2);
let f = s.take().unwrap();
let f = s.take(|_| true).unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
assert_eq!(
s.take(|_| true),
Some(1),
"the put-back frame is still first"
);
}
/// A frame held for its due time is the smoothing intent working, not the store
/// running dry: nothing is counted and the preroll it built stays armed. Counting it
/// as an underflow would re-arm the preroll on every well-paced frame and stall the
/// stream for a buffer's worth of frames each time.
#[test]
fn a_frame_held_for_its_due_time_is_not_an_underflow() {
let mut s: FrameStore<u32> = FrameStore::new(2);
s.submit(10);
s.submit(20);
assert_eq!(s.take(|_| false), None, "prerolled, but nothing is due yet");
assert_eq!(s.take(|&v| v >= 10), Some(10));
assert_eq!(s.take(|&v| v >= 30), None, "20 is not due yet either");
assert_eq!(s.take(|_| true), Some(20));
// NOW it is genuinely dry, which is an underflow and does re-arm the preroll.
assert_eq!(s.take(|_| true), None);
s.submit(30);
assert_eq!(s.take(|_| true), None, "re-armed preroll holds again");
assert_eq!(
s.take_counters(),
(0, 0, 1),
"one dry, nothing from the holds"
);
}
/// The desktop half of the intent split: a newest-wins store vends on arrival and
/// never so much as ASKS for a due time, so no cadence clock can end up gating the
/// latency intent even if a caller handed it one.
#[test]
fn the_latency_store_vends_without_ever_asking_a_due_time() {
let mut s: FrameStore<u32> = FrameStore::new(0);
s.submit(7);
let mut asked = false;
let got = s.take(|_| {
asked = true;
false
});
assert_eq!(
got,
Some(7),
"arrival-driven: the frame goes out regardless"
);
assert!(!asked, "…and the due time was never consulted");
}
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
@@ -534,10 +696,14 @@ mod tests {
s.submit(3);
s.force_latency();
assert!(!s.is_smoothing());
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
assert_eq!(
s.take(|_| true),
Some(3),
"only the newest survives the collapse"
);
s.submit(4);
s.submit(5);
assert_eq!(s.take(), Some(5));
assert_eq!(s.take(|_| true), Some(5));
}
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
@@ -732,6 +898,116 @@ mod tests {
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
}
/// 120 Hz, and a source stamping a clock domain far from the present clock's — the
/// pacer must never need to be told about the difference.
const SRC_P: i64 = 8_333_333;
const SRC_PTS0: u64 = 1_786_000_000_000_000_000;
const SRC_READY0: u64 = 1_000_000_000;
/// Fold `n` frames of a clean 120 Hz source through the pacer.
fn fold(p: &mut SourcePacer, smoothing: bool, n: u64) {
for k in 0..n {
p.due_ns(
smoothing,
SRC_PTS0 + k * SRC_P as u64,
SRC_READY0 + k * SRC_P as u64,
SRC_P,
);
}
}
/// The clock half of the intent split: under latency the pacer folds NOTHING. The
/// estimate exists only where it is used, so the latency path costs exactly what it
/// cost before this work and a stream that collapses to latency mid-flight (PyroWave)
/// leaves no half-built loop behind it.
#[test]
fn the_latency_intent_folds_no_frames_into_the_cadence_clock() {
let mut p = SourcePacer::new();
for k in 0..64u64 {
assert_eq!(
p.due_ns(
false,
SRC_PTS0 + k * SRC_P as u64,
SRC_READY0 + k * SRC_P as u64,
SRC_P
),
None,
"latency has no due time to answer with"
);
}
let h = p.health();
assert_eq!(h.frames, 0, "not one sample reached the loop");
assert_eq!((h.offset_ns, h.skew_ns, h.jitter_ns), (0, 0, 0));
// …and the very same frames under smoothness do reach it.
assert!(p.due_ns(true, SRC_PTS0, SRC_READY0, SRC_P).is_some());
assert_eq!(p.health().frames, 1);
}
/// One domain in, same domain out (the clock's own invariant, asserted here because
/// this binding is the one that feeds `decoded_ns` and reads back a `now_ns` deadline
/// with no conversion between them): the due time lands on the PRESENT clock's
/// timeline, however far the source's stamps are from it.
#[test]
fn a_due_time_comes_back_on_the_present_clocks_timeline() {
let mut p = SourcePacer::new();
fold(&mut p, true, 400);
let k = 400u64;
let due = p
.due_ns(
true,
SRC_PTS0 + k * SRC_P as u64,
SRC_READY0 + k * SRC_P as u64,
SRC_P,
)
.unwrap();
let ready = (SRC_READY0 + k * SRC_P as u64) as i64;
assert!(
(due - ready).abs() <= p.health().cushion_ns,
"due {due} is not within a cushion of the present-clock ready {ready}"
);
}
/// The VRR half: where variable refresh is MEASURED live there is no grid to snap to,
/// so the due time is presented directly and the cushion has to cover the distribution
/// on its own. Re-tuning is a fresh loop, which is why it follows the probe's published
/// verdict — agreed across rounds — and not a per-window reading.
#[test]
fn a_measured_vrr_verdict_switches_the_cushion_policy() {
let mut p = SourcePacer::new();
assert!(!p.free_running(), "snapping until the panel says otherwise");
fold(&mut p, true, 200);
p.follow(Cadence::Fixed);
assert!(!p.free_running());
assert_eq!(
p.health().frames,
200,
"a verdict that changes nothing must not re-anchor"
);
p.follow(Cadence::Variable);
assert!(p.free_running());
assert_eq!(p.health().frames, 0, "re-tuning is a fresh loop");
p.follow(Cadence::Unknown);
assert!(
!p.free_running(),
"Unknown is the absence of a measurement, not a measurement of VRR"
);
// The two tunings differ where it matters: with no jitter measured yet, the
// free-running cushion already holds a frame back by more than the snapping one,
// which is riding on the half-refresh the snap-up gives it for free.
let mut snap = SourcePacer::new();
snap.due_ns(true, SRC_PTS0, SRC_READY0, SRC_P);
let mut free = SourcePacer::new();
free.follow(Cadence::Variable);
free.due_ns(true, SRC_PTS0, SRC_READY0, SRC_P);
assert!(
free.health().cushion_ns > snap.health().cushion_ns,
"free-running {} must cushion past snapping {}",
free.health().cushion_ns,
snap.health().cushion_ns
);
}
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
#[test]
fn gate_budgets_one_undisplayed_present() {
+138 -38
View File
@@ -21,7 +21,8 @@ use crate::overlay::{
FrameCtx, Overlay, OverlayAction, OverlayFrame, PointerButton, PointerInput, SessionPhase,
};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, SourcePacer, MARGIN_MAX_NS,
MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
@@ -209,6 +210,17 @@ enum ModeCtl<'a> {
/// pure wake-up — the loop drains the frame channel regardless of why it woke.
struct FrameWake;
/// A decoded frame and when the source cadence says it is due on glass — the pair the
/// intent store holds, so the due time is decided from the arrival process the store SAW
/// rather than from whatever survived it.
struct Paced {
frame: DecodedFrame,
/// In `session::now_ns`'s domain (`DecodedFrame::decoded_ns` is the same clock, which
/// is what lets this path run with no conversion in it). `0` under the latency intent,
/// which never asks.
due_ns: i64,
}
/// Everything one stream session accumulates — created at session start, dropped at
/// session end (browse mode cycles through several per process lifetime).
struct StreamState {
@@ -265,10 +277,18 @@ struct StreamState {
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
store: FrameStore<DecodedFrame>,
store: FrameStore<Paced>,
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
clock: LatchClock,
/// Plays smoothness frames out on the SOURCE's cadence instead of on their arrival
/// instant — inert under latency, which never folds a frame into it.
pacer: SourcePacer,
/// The SOURCE's nominal frame interval: the negotiated STREAM mode's refresh, never
/// the panel's. It is the cushion's ceiling, so it has to describe the cadence the host
/// produces — on a 120 fps stream shown on a 60 Hz panel the panel's period would
/// license twice the hold the source's own cadence can justify.
source_interval_ns: i64,
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
/// or without present timing.
gate: PresentGate,
@@ -279,9 +299,6 @@ struct StreamState {
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
/// period (see the probe's call site).
mode_period_ns: u64,
/// The latch slot the last smoothness present served (one present per slot); 0 =
/// none yet.
last_target_ns: u64,
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
margin_ns: u64,
@@ -385,6 +402,9 @@ impl StreamState {
native_refresh_hz: u32,
) -> StreamState {
let profile = params.profile.clone();
// The rate we ASKED for, until the Welcome resolves it (`Connected` below). No
// frames flow before that, so this only ever has to be sane, not right.
let source_interval_ns = frame_interval_ns(params.mode.refresh_hz, native_refresh_hz);
// The presenter's half of phase-locked capture: it writes the latch grid the
// pump reads (see `LatchGrid`), so keep the Arc before the params move. `None`
// when the session didn't advertise the cap — the 1 Hz fold then skips the work.
@@ -431,10 +451,11 @@ impl StreamState {
presented: PresentedWindow::default(),
store: FrameStore::new(usize::from(priority.fifo_capacity())),
clock: LatchClock::new(native_refresh_hz),
pacer: SourcePacer::new(),
source_interval_ns,
gate: PresentGate::default(),
cadence: CadenceProbe::new(),
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
last_target_ns: 0,
margin_ns: 0,
win_misses: 0,
win_out_max: 0,
@@ -481,26 +502,61 @@ impl StreamState {
self.handle.stop.store(true, Ordering::SeqCst);
}
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
/// tick (frames, input, and present completions all wake the loop early anyway).
/// The event-loop wait bound: a smoothness stream with a frame in hand sleeps only to
/// the pass that can still serve it; everything else keeps the 15 ms housekeeping tick
/// (frames, input, and present completions all wake the loop early anyway).
///
/// ⚠ This is the present decision's mirror and has to stay one — it answers "when does
/// that decision first say yes?", so a rule changed on one side and not the other
/// oversleeps a smooth stream straight past its own due time.
fn wake_timeout(&self) -> Duration {
const TICK: Duration = Duration::from_millis(15);
if !self.store.is_smoothing() || self.store.is_empty() {
if !self.store.is_smoothing() {
return TICK;
}
let now = session::now_ns();
let mut target = self
.clock
.next_slot_after(now.saturating_add(self.margin_ns));
if target == self.last_target_ns {
// This slot is already served — the next boundary is the deadline.
target += self.clock.period_ns();
}
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
let Some(p) = self.store.front() else {
return TICK;
};
// Free-running presents at the due time itself. Snapping presents once the slot the
// frame is aimed at is the next one still reachable — one period, less the submit
// lead, before it. Before the first on-glass stamp there is no grid and
// `next_slot_after` answers "one period from the query" instead, so the decision's
// slot moves with `now` and the frame is servable a period sooner; mirror that too,
// or a session's opening frames sit in the store for a refresh they never owed.
let lead_ns = self.clock.period_ns() as i64 + self.margin_ns as i64;
let wake_ns = if self.pacer.free_running() {
p.due_ns
} else if self.clock.anchor_ns() == 0 {
p.due_ns - lead_ns
} else {
self.clock.next_slot_after(p.due_ns.max(0) as u64) as i64 - lead_ns
};
Duration::from_nanos(wake_ns.saturating_sub(session::now_ns() as i64).max(0) as u64)
.clamp(Duration::from_millis(1), TICK)
}
}
/// One frame at `refresh_hz`, in ns — the SOURCE's nominal interval, and so the cadence
/// cushion's ceiling.
///
/// The negotiated stream mode's refresh is the only source-rate signal a client has, and it
/// is the right one: it is the rate the host's virtual output runs at, so it bounds the
/// cadence the capture can produce. The MEASURED fps would be the tempting alternative and
/// is the wrong answer — it sags exactly when the transport is struggling, which is when a
/// ceiling derived from it would start licensing a bigger hold.
///
/// `0` = the mode asked for "native", which the host resolves to the display this client
/// reported; that display's rate is therefore what it will produce. Neither known falls
/// back to 60 Hz, the same last resort [`native_mode`]'s caller takes.
fn frame_interval_ns(refresh_hz: u32, fallback_hz: u32) -> i64 {
let hz = match (refresh_hz, fallback_hz) {
(0, 0) => 60,
(0, f) => f,
(r, _) => r,
};
1_000_000_000 / i64::from(hz)
}
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
/// device is unrecoverable by spec — every object on it (decoder frames, swapchain,
/// the Skia context) is dead, and the demote-to-software path would rebuild the
@@ -857,7 +913,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
}
st.cadence.reset();
st.last_target_ns = 0;
// The estimate was built against a panel this stream is no
// longer on, and the verdict its cushion policy came from has
// just been thrown away with it. Re-anchoring costs one frame;
// the measured jitter survives, because that describes the link.
st.pacer.reset();
tracing::info!(
refresh_hz = hz,
"display changed — relearning the latch grid"
@@ -1394,6 +1454,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
continue;
}
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
// The RESOLVED rate — a `0 = native` request becomes a real number
// here, and this is the last moment before frames start arriving.
st.source_interval_ns = frame_interval_ns(m.refresh_hz, native.refresh_hz);
tracing::info!(mode = %st.mode_line, "connected");
window
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
@@ -1904,26 +1967,39 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
);
}
}
st.store.submit(f);
// The intent AFTER any PyroWave collapse above, so a wavelet stream folds
// nothing into a loop it will never consult.
let smoothing = st.store.is_smoothing();
let due_ns = st
.pacer
.due_ns(smoothing, f.pts_ns, f.decoded_ns, st.source_interval_ns)
.unwrap_or(0);
st.store.submit(Paced { frame: f, due_ns });
}
// One frame out, by intent: latency takes the newest whenever the glass
// gate allows; smoothness serves at most one frame per latch slot (the
// gate allows; smoothness serves the frame whose due time has come (the
// preroll/underflow behavior lives in the store).
let now_ns = session::now_ns();
let mut slot_target = 0u64;
st.pacer.follow(st.cadence.verdict());
let mut to_present = if st.store.is_smoothing() {
let target = st
.clock
.next_slot_after(now_ns.saturating_add(st.margin_ns));
if target != st.last_target_ns {
slot_target = target;
st.store.take()
if st.pacer.free_running() {
// Variable refresh, measured: the panel refreshes when we present, so
// there is no grid to aim at and the due time IS the target.
st.store.take(|p| p.due_ns <= now_ns as i64)
} else {
None
// The first latch still reachable from here, given the submit lead. A
// frame due before it cannot be shown any sooner by waiting; one due
// after it would land a slot early, which is the judder this exists to
// remove. (`next_slot_after` is monotone, so "its own target slot is
// not later than this one" reduces to the comparison below.)
let slot = st
.clock
.next_slot_after(now_ns.saturating_add(st.margin_ns));
st.store.take(|p| p.due_ns < slot as i64)
}
} else {
st.store.take()
st.store.take(|_| true)
};
// The FIFO glass budget: one undisplayed present in flight, so the
// swapchain's own FIFO can never become a standing queue (a measured
@@ -1942,7 +2018,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
}
if let Some(f) = to_present {
if let Some(Paced { frame: f, .. }) = to_present {
// Resize END: a frame at the steered target size means the sharp new-mode
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
@@ -2168,12 +2244,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
};
if did_present {
presented_video = true;
// Smoothness: this latch slot is served — one present per slot.
// (Set only on success: a gated or failed present leaves the slot
// open for the retry.)
if slot_target != 0 {
st.last_target_ns = slot_target;
}
if opts.json_status && !st.ready_announced {
st.ready_announced = true;
println!("{{\"ready\":true}}");
@@ -2261,6 +2331,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
let cadence_health = st.pacer.health();
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
@@ -2276,6 +2347,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
// The cadence loop's current hold and the jitter it is sized from,
// plus the frames whose due time had already passed when they
// arrived — the direct read on whether the cushion is big enough.
// All three are cumulative/instantaneous, NOT window sums like the
// counters above: the loop's state is what a triage session wants,
// and a one-second slice of a running estimate says nothing.
cushion_us = cadence_health.cushion_ns / 1000,
jitter_us = cadence_health.jitter_ns / 1000,
late = cadence_health.late,
"presenter window"
);
}
@@ -2391,6 +2471,11 @@ fn hud_mode_tick(st: &mut StreamState, window: &mut sdl3::video::Window, title_b
st.mode_line = format!("{}×{}@{}", m.width, m.height, m.refresh_hz);
tracing::info!(mode = %st.mode_line, "stream mode switched");
let _ = window.set_title(&format!("{title_base} · {}", st.mode_line));
// A switch is a full host-side rebuild of the virtual display and the encoder: the
// interval the cushion is bounded by can change, and the gap the rebuild leaves is
// a hole the cadence estimate must re-anchor across rather than slew over.
st.source_interval_ns = frame_interval_ns(m.refresh_hz, 0);
st.pacer.reset();
}
st.shown_mode = Some(m);
}
@@ -3216,6 +3301,21 @@ mod tests {
assert_eq!((m.width, m.height), (0, 0));
}
/// The cadence cushion is bounded by the SOURCE's frame interval, and the negotiated
/// stream mode is where that number comes from. Substituting the panel's period —
/// the tempting simplification, since the presenter has one at hand — would let a
/// 120 fps stream on a 60 Hz panel hold a frame for twice the source's own cadence.
#[test]
fn the_cadence_interval_comes_from_the_stream_mode_not_the_panel() {
assert_eq!(frame_interval_ns(120, 60), 8_333_333);
assert_eq!(frame_interval_ns(60, 165), 16_666_666);
// A `0 = native` request is resolved by the host to the display this client
// reported, so that display's rate is what it will produce.
assert_eq!(frame_interval_ns(0, 165), 6_060_606);
// Neither known: 60 Hz, never an unbounded ceiling.
assert_eq!(frame_interval_ns(0, 0), 16_666_666);
}
#[test]
fn overlay_scale_follows_dpi_and_survives_a_bogus_display() {
// 100 % / 96 dpi is the identity — the chrome keeps the size it always had.
+158 -3
View File
@@ -710,6 +710,13 @@ struct AudioPcmState {
/// frame. 0 until the first decode, which skips concealment (nothing to size it from),
/// exactly like the other clients.
frame_samples: usize,
/// Frames synthesized for a packet DROUGHT since the last real packet
/// ([`punktfunk_connection_audio_plc`]), subtracted from the loss concealment the next packet
/// asks for: a packet genuinely lost inside a drought this already covered must not be covered
/// twice, which would insert audio the stream never carried and push everything after it
/// later. The subtraction has to happen HERE — the gap tracker is on this side of the ABI, so
/// the embedder driving the drought cannot see what it is about to be charged for.
drought_frames: u32,
}
#[cfg(feature = "quic")]
@@ -748,8 +755,12 @@ impl AudioPcmState {
// Conceal lost packets (a seq gap) before decoding the one that arrived: empty input
// synthesizes `frame_samples` of interpolation per missing packet — an inaudible fade
// instead of the click a hard gap makes in the ring. Mirrors the Linux/Windows session
// pump and the Android native pump; capped by the tracker at 50 ms.
let missing = self.gaps.missing_before(seq);
// pump and the Android native pump; capped by the tracker at 50 ms. Whatever a drought
// already covered comes off the top — that audio is in the embedder's ring already.
let missing = self
.gaps
.missing_before(seq)
.saturating_sub(std::mem::take(&mut self.drought_frames));
let mut filled = 0usize;
if self.frame_samples > 0 {
for _ in 0..missing {
@@ -779,6 +790,32 @@ impl AudioPcmState {
Err(_) => Err(PunktfunkStatus::BadPacket),
}
}
/// Synthesize ONE concealment frame with no packet involved — the drought half of concealment
/// (see [`punktfunk_connection_audio_plc`] for what asks for it and why).
///
/// Returns the interleaved sample count now valid at the front of `pcm`, or `Ok(0)` when
/// nothing has decoded yet: libopus PLC extrapolates from the LAST decoded frame, so before
/// there is one there is neither state to extrapolate from nor a frame size to ask for.
fn conceal(&mut self, channels: u8) -> Result<usize, PunktfunkStatus> {
let ch = channels as usize;
let plc = self.frame_samples * ch;
if plc == 0 {
return Ok(0);
}
let Some(dec) = self.decoder.as_mut() else {
return Ok(0);
};
match dec.decode_float(&[], &mut self.pcm[..plc], false) {
Ok(samples) => {
self.drought_frames = self.drought_frames.saturating_add(1);
Ok(samples * ch)
}
// libopus declined to interpolate. Nothing to hand out, and the caller's response is
// a timeout's: write nothing and let its ring's own underrun path have the drought.
Err(_) => Ok(0),
}
}
}
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
@@ -2577,7 +2614,9 @@ pub struct PunktfunkAudioPcm {
/// IN FRONT of the arriving frame in the same buffer — `out->frame_count` then covers the
/// concealed frames plus the real one (`out->seq`/`out->pts_ns` are the real packet's). The
/// embedder just writes the whole buffer to its ring, same as any other frame; gaps arrive
/// pre-healed, exactly as they do on the clients that decode outside core.
/// pre-healed, exactly as they do on the clients that decode outside core. That covers a gap a
/// LATER packet reveals; when the wire goes quiet instead, see
/// [`punktfunk_connection_audio_plc`].
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
@@ -2630,6 +2669,78 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
})
}
/// Synthesize ONE frame of concealment from the in-core decoder's own state — no packet involved,
/// nothing pulled off the wire (design/host-source-stutter-fixes.md, WP-C1).
///
/// [`punktfunk_connection_next_audio_pcm`] heals a gap the SEQUENCE reveals, which needs a later
/// packet to arrive and reveal it. When the wire simply goes quiet — a delivery stall on a
/// bunching Wi-Fi link, or a host whose capture stalled — nothing arrives to reveal anything: the
/// embedder's playout ring drains to empty, its callback runs short, and its de-jitter policy
/// de-primes and then re-primes a whole target's worth of fresh silence. The artifact is far
/// longer than the audio actually missing.
///
/// So on a `NO_FRAME` timeout with a DRAINING ring, ask for this instead. The policy stays on the
/// embedder's side because that is where its two ingredients live — the ring depth and the clock
/// since the last packet — and it must be: bounded in TIME (roughly twice the ring's own de-prime
/// fuse), never in callbacks or frames, and gated on the ring genuinely running out. A drought a
/// deep ring covers is inaudible, and concealing it would insert audio the late packets are about
/// to duplicate, pushing the stream permanently later. Core supplies only the mechanism, one frame
/// per call, at the cadence the embedder drains at.
///
/// Returns [`PunktfunkStatus::NoFrame`] when nothing has decoded yet — PLC extrapolates from the
/// last decoded frame, so before there is one there is no state to extrapolate from — and if
/// libopus declines to interpolate. Both mean "write nothing this tick", exactly like a timeout.
///
/// `out->seq` and `out->pts_ns` read 0: this frame was never on the wire, so it has no sequence
/// number and no capture instant, and it must never be fed to an A/V-sync observation.
/// `out->samples` borrows connection memory until the next PCM call on this handle — the SAME
/// slot [`punktfunk_connection_next_audio_pcm`] hands out, so call both from the one audio thread.
///
/// Frames taken this way are subtracted from the concealment the next arriving packet asks for, so
/// a packet genuinely lost inside a covered drought is not concealed twice.
///
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_plc(
c: *mut PunktfunkConnection,
out: *mut PunktfunkAudioPcm,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
if out.is_null() {
return PunktfunkStatus::NullPointer;
}
let channels = crate::audio::normalize_channels(c.inner.audio_channels);
let mut state = lock_recover(&c.audio_pcm);
match state.conceal(channels) {
Ok(0) => PunktfunkStatus::NoFrame,
Ok(samples) => {
// SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the
// matching `#[repr(C)]` type, written once by value.
unsafe {
*out = PunktfunkAudioPcm {
samples: state.pcm.as_ptr(),
frame_count: (samples / channels.max(1) as usize) as u32,
channels,
seq: 0,
pts_ns: 0,
};
}
PunktfunkStatus::Ok
}
Err(status) => status,
}
})
}
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
@@ -5294,4 +5405,48 @@ mod tests {
Ok((crate::audio::MAX_CONCEAL_PACKETS as usize + 1) * FRAME * 2)
);
}
/// Drought concealment (WP-C1): the embedder asks for a frame at a time while the wire is
/// quiet, and the loss path must then charge only for what the drought did NOT already cover
/// — concealing a packet lost inside a covered drought a second time would insert audio the
/// stream never carried and push everything after it later. The subtraction lives here
/// because the gap tracker does; the embedder driving the drought cannot see it.
#[test]
fn drought_concealment_is_not_charged_again_by_the_loss_path() {
const FRAME: usize = 240; // 5 ms @ 48 kHz, per channel
let l = crate::audio::LAYOUT_STEREO;
let mut enc = opus::MSEncoder::new(
48_000,
l.streams,
l.coupled,
l.mapping,
opus::Application::LowDelay,
)
.expect("MSEncoder");
enc.set_vbr(false).unwrap();
let mut packet = |tone: f32| {
let mut frame = vec![0f32; FRAME * 2];
for (i, s) in frame.iter_mut().enumerate() {
*s = 0.25 * (i as f32 * tone).sin();
}
let mut out = vec![0u8; 1500];
let n = enc.encode_float(&frame, &mut out).unwrap();
out.truncate(n);
out
};
let mut state = AudioPcmState::default();
// Nothing has decoded: PLC has no state to extrapolate from, and the ABI reports NoFrame.
assert_eq!(state.conceal(2), Ok(0));
assert_eq!(state.decode_packet(&packet(0.05), 0, 2), Ok(FRAME * 2));
// The wire goes quiet; the embedder covers four frames of it.
for _ in 0..4 {
assert_eq!(state.conceal(2), Ok(FRAME * 2));
}
// It comes back at seq 7 — six packets missing, four of them already in the ring.
assert_eq!(state.decode_packet(&packet(0.06), 7, 2), Ok(3 * FRAME * 2));
// …and the next drought starts from nothing owed, not from a stale credit.
assert_eq!(state.decode_packet(&packet(0.06), 9, 2), Ok(2 * FRAME * 2));
}
}
+169
View File
@@ -470,6 +470,15 @@ impl JitterTuning {
deprime_ms: 60,
};
/// How long a packet DROUGHT may be concealed before the ring is allowed to underrun and the
/// de-prime hysteresis is allowed to run (WP-C1). Twice the de-prime window: long enough to
/// ride out the delivery stalls that de-prime rings today, short enough that a genuinely dead
/// stream is not papered over. DERIVED rather than a fifth field, so it cannot drift away from
/// the fuse it exists to protect — and per-platform for free, since `deprime_ms` already is.
pub const fn plc_max_ms(&self) -> u32 {
self.deprime_ms * 2
}
/// How far above the live target the depth average must sit before drift correction sheds:
/// the middle of the headroom band, but never less than two protocol frames (so it cannot be
/// hair-triggered by one quantum of normal swing). Deriving it from `headroom_ms` rather than
@@ -485,6 +494,79 @@ impl JitterTuning {
}
}
/// A drought must outlast ordinary arrival jitter before anything is synthesized for it: two
/// protocol frames, the same tolerance the host's capture-hole infill uses at the other end.
const DROUGHT_AFTER: std::time::Duration = std::time::Duration::from_millis(2 * FRAME_MS as u64);
/// …and the ring must actually be running out. A drought a deep ring can cover is not audible,
/// and concealing it would synthesize audio the late packets are about to duplicate — pushing the
/// whole stream later and handing the drift shed a mess to clean up audibly.
const DROUGHT_FLOOR_MS: u32 = 2 * FRAME_MS;
/// Bounded concealment of a packet DROUGHT — the client-side twin of the host's capture-hole
/// infill (design/host-source-stutter-fixes.md, WP-C1).
///
/// The decode path already conceals a SEQ GAP: [`AudioGapTracker`] reports the packets missing
/// before the one that arrived and libopus synthesizes each from the decoder's own state. But that
/// only fires when a LATER packet arrives to reveal the gap. When the wire simply goes quiet — a
/// delivery stall on a bunching Wi-Fi link, or a host whose capture stalled — nothing arrives to
/// reveal anything: the ring drains to empty, the callback runs short, and
/// [`JitterPolicy::note_read`] de-primes and then re-primes a whole target's worth of fresh
/// silence. The artifact is far longer than the audio actually missing, and this is the shape the
/// 2026-08-15 field session spent 316 % of its wall-clock in.
///
/// So a drought that is draining the ring gets concealed too, from the same decoder state, for a
/// bounded time. Denominated in TIME, never in frames or callbacks: that is the recorded lesson
/// from the very fuse this protects, where a count gave an iPad a third of a Mac's slack for no
/// reason anyone intended.
///
/// Time is passed IN, so the policy stays as syscall-free and deterministic as the rest of this
/// module.
pub struct DroughtConceal {
/// Concealed since the last real packet.
concealed_ms: u32,
max_ms: u32,
/// Concealed over the session — what the 10 s `plc_ms=` line reports. Concealment must be
/// visible: a policy that quietly papers over a failing link is a policy that hides the bug.
total_ms: u64,
}
impl DroughtConceal {
pub fn new(max_ms: u32) -> DroughtConceal {
DroughtConceal {
concealed_ms: 0,
max_ms,
total_ms: 0,
}
}
/// A packet arrived, ending any drought. Returns how many FRAMES were concealed for it, so the
/// caller can subtract them from the loss concealment [`AudioGapTracker`] is about to ask for:
/// packets genuinely lost inside a drought we already covered must not be covered twice, which
/// would insert audio the stream never had and push everything after it later.
pub fn packet(&mut self) -> u32 {
std::mem::take(&mut self.concealed_ms) / FRAME_MS
}
/// Should one more frame be concealed? `depth_ms` is the playout ring as the callback last
/// saw it.
pub fn conceal(&mut self, since_last_packet: std::time::Duration, depth_ms: u32) -> bool {
if since_last_packet < DROUGHT_AFTER
|| depth_ms > DROUGHT_FLOOR_MS
|| self.concealed_ms >= self.max_ms
{
return false;
}
self.concealed_ms += FRAME_MS;
self.total_ms += FRAME_MS as u64;
true
}
/// Concealment over the session, ms.
pub fn total_ms(&self) -> u64 {
self.total_ms
}
}
/// What one callback should do, from [`JitterPolicy::step`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct JitterStep {
@@ -1066,6 +1148,11 @@ pub fn spa_positions(channels: u8) -> &'static [u32] {
pub struct AudioSyncCell {
depth: std::sync::atomic::AtomicUsize,
target: std::sync::atomic::AtomicUsize,
/// Concealment the decode side has synthesized this session, ms — telemetry travelling the
/// same way the target does. It rides here because the counter is produced on the decode
/// thread and the 10 s playback line is emitted from the callback, and concealment that
/// nobody can see is concealment that hides the bug it is covering (WP-C1, risk R6).
plc_ms: std::sync::atomic::AtomicU64,
}
impl Default for AudioSyncCell {
@@ -1073,6 +1160,7 @@ impl Default for AudioSyncCell {
AudioSyncCell {
depth: std::sync::atomic::AtomicUsize::new(0),
target: std::sync::atomic::AtomicUsize::new(usize::MAX),
plc_ms: std::sync::atomic::AtomicU64::new(0),
}
}
}
@@ -1089,6 +1177,16 @@ impl AudioSyncCell {
self.depth.load(std::sync::atomic::Ordering::Relaxed)
}
/// Decode side: publish total concealment synthesized for packet droughts.
pub fn publish_plc_ms(&self, ms: u64) {
self.plc_ms.store(ms, std::sync::atomic::Ordering::Relaxed);
}
/// Callback side: that total, for the periodic playback line.
pub fn plc_ms(&self) -> u64 {
self.plc_ms.load(std::sync::atomic::Ordering::Relaxed)
}
/// Decode side: ask the ring to aim for this depth (`None` = run unsynchronised).
pub fn set_target(&self, target: Option<usize>) {
self.target.store(
@@ -1415,6 +1513,77 @@ mod tests {
);
}
// ---- drought concealment (WP-C1) -----------------------------------------------------
/// Concealment is for a ring that is running OUT. A drought a deep ring can cover is
/// inaudible, and synthesizing over it would insert audio the late packets are about to
/// duplicate — the stream would then run permanently later and the drift shed would have to
/// cut it back out, audibly.
#[test]
fn a_drought_is_concealed_only_while_the_ring_is_running_out() {
let mut c = DroughtConceal::new(JitterTuning::PIPEWIRE.plc_max_ms());
let stalled = DROUGHT_AFTER + std::time::Duration::from_millis(FRAME_MS as u64);
assert!(
!c.conceal(stalled, 40),
"a 40 ms ring covers this drought by itself"
);
assert!(c.conceal(stalled, 0), "an empty ring does not");
assert_eq!(c.total_ms(), FRAME_MS as u64);
}
/// Ordinary arrival jitter is not a drought — this policy must be invisible until the wire
/// has genuinely stopped.
#[test]
fn ordinary_jitter_is_not_a_drought() {
let mut c = DroughtConceal::new(JitterTuning::AAUDIO.plc_max_ms());
for _ in 0..1_000 {
assert!(!c.conceal(std::time::Duration::from_millis(FRAME_MS as u64), 0));
}
assert_eq!(c.total_ms(), 0);
assert_eq!(c.packet(), 0);
}
/// The window is bounded, and bounded in TIME — the whole reason `deprime_ms` stopped being a
/// callback count. Every preset must get exactly twice its own de-prime fuse, so no platform
/// silently gets a third of another's protection again.
#[test]
fn drought_concealment_is_bounded_at_twice_the_deprime_fuse() {
for t in [
JitterTuning::PIPEWIRE,
JitterTuning::WASAPI,
JitterTuning::COREAUDIO,
JitterTuning::AAUDIO,
] {
assert_eq!(t.plc_max_ms(), t.deprime_ms * 2);
let mut c = DroughtConceal::new(t.plc_max_ms());
let mut ms = 0u32;
while c.conceal(DROUGHT_AFTER, 0) {
ms += FRAME_MS;
assert!(ms <= t.plc_max_ms(), "ran past the budget for {t:?}");
}
assert_eq!(ms, t.plc_max_ms(), "must use exactly the budget for {t:?}");
}
}
/// Packets genuinely lost INSIDE a drought we already covered must not be covered a second
/// time by the loss path: doing both would insert audio the stream never carried and push
/// everything after it later.
#[test]
fn concealment_already_paid_for_is_not_paid_for_twice() {
let mut c = DroughtConceal::new(JitterTuning::WASAPI.plc_max_ms());
for _ in 0..4 {
assert!(c.conceal(DROUGHT_AFTER, 0));
}
let mut gaps = AudioGapTracker::new();
gaps.missing_before(10);
// Four frames concealed; the wire then reveals six were lost. Only two are still owed.
let already = c.packet();
assert_eq!(already, 4);
assert_eq!(gaps.missing_before(17).saturating_sub(already), 2);
// …and the next drought starts from a full budget.
assert!(c.conceal(DROUGHT_AFTER, 0));
}
// ---- bitrate tiers -------------------------------------------------------------------
/// `Standard` must reproduce the historical table EXACTLY — that is what makes the tier
+13 -1
View File
@@ -208,7 +208,19 @@ pub use stats::Stats;
/// way). Additive and client-local: the mask, the expiry and the `AccessUpdate` message all
/// shipped with the Welcome's trailing-field append (old peers skip them in both directions),
/// so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 22;
/// v23: added `punktfunk_connection_audio_plc` — one frame of libopus packet-loss concealment,
/// synthesized from the connection's OWN decoder state, for an embedder whose playout ring is
/// draining because nothing is arriving (design/host-source-stutter-fixes.md WP-C1). The three
/// Rust clients conceal a packet drought on their decode thread; Apple's ring is Swift and its
/// decoder sits behind this ABI, so without a call it had no way to reach the one thing that can
/// extrapolate the missing audio — a second decoder would conceal from empty state, because PLC
/// extrapolates from the last decoded frame. A NEW symbol: every existing function keeps its
/// signature and behaviour, and an embedder that never calls it behaves exactly as before (it
/// simply de-primes over droughts, as all four clients used to). Frames it returns carry `seq`
/// and `pts_ns` of `0` — concealed audio was never on the wire and must not reach an A/V-sync
/// observation. Additive and client-local: nothing new is sent or parsed, so [`WIRE_VERSION`] is
/// unchanged.
pub const ABI_VERSION: u32 = 23;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+585
View File
@@ -129,6 +129,261 @@ pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)>
Some((mean_ns, (r * 1000.0) as u16))
}
// ---- Source-timestamp playout (design/presenter-cadence-rework.md, WP3) --------------------
/// Tuning for one cadence loop. Gains are SHIFT COUNTS — the loop is fixed-point i64 throughout,
/// so it runs identically on every client and in the offline harness, and carries no float into a
/// present path.
///
/// ⚠ **These values are provisional, and saying so is part of the design.** The plan asks for
/// constants fitted to recorded `(src_pts, received, decoded)` traces (its spike S2), and S2 was
/// never run — the 2026-08-05 baseline records that omission itself. What is here is derived from
/// first principles (a proportional time constant of tens of frames, an integral an order slower,
/// a cushion of a few mean-absolute-deviations) and is honest about being a starting point. The
/// first real trace should replace them, with the trace named beside each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CadenceTuning {
/// Proportional gain on the offset estimate: `1 >> offset_shift` of the residual per frame.
pub offset_shift: u8,
/// Integral gain on the per-frame rate (skew) term: `1 >> skew_shift`.
pub skew_shift: u8,
/// EMA weight for the residual mean-absolute-deviation.
pub jitter_shift: u8,
/// Per-sample residual clamp — one outlier must not yank the estimate.
pub error_clamp_ns: i64,
/// Cushion = `mad * cushion_num / cushion_den`, clamped to
/// `[cushion_floor_ns, frame_interval_ns]`.
pub cushion_num: u16,
pub cushion_den: u16,
pub cushion_floor_ns: i64,
/// Source-timestamp gap beyond which the loop re-anchors instead of tracking.
pub reanchor_gap_ns: i64,
}
impl CadenceTuning {
/// For callers that snap the due time onto a display grid afterwards: the snap-up itself
/// carries roughly half a refresh of implicit slack, so the cushion can be small.
pub const fn snapping() -> CadenceTuning {
CadenceTuning {
offset_shift: 5,
skew_shift: 10,
jitter_shift: 5,
error_clamp_ns: 20_000_000,
cushion_num: 2,
cushion_den: 1,
cushion_floor_ns: 500_000,
reanchor_gap_ns: 500_000_000,
}
}
/// For callers presenting at the due time directly (VRR, direct scanout): no implicit slack,
/// so the cushion must cover more of the distribution on its own.
pub const fn free_running() -> CadenceTuning {
CadenceTuning {
cushion_num: 3,
cushion_floor_ns: 2_000_000,
..CadenceTuning::snapping()
}
}
}
/// Loop health for the 1 Hz line — the numbers that say whether the cushion is doing its job.
///
/// Residual PERCENTILES are deliberately absent: this type is allocation-free and holds no
/// histogram, and the client stat paths (the judder metric) are where distributions belong.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CadenceHealth {
/// Frames folded since the last [`CadenceClock::reset`].
pub frames: u64,
/// …of which the due time was already past when the frame became presentable. The direct
/// signal that the cushion is too small.
pub late: u64,
/// Times the loop gave up tracking and re-anchored (gap, regression, or explicit reset).
pub reanchors: u64,
pub offset_ns: i64,
pub skew_ns: i64,
pub jitter_ns: i64,
pub cushion_ns: i64,
}
/// Plays frames out on the SOURCE's cadence instead of on their arrival instant.
///
/// The defect it exists for: every client presents a frame as soon as it is decoded, so the
/// transport's jitter — and, on a host whose compositor delivers raggedly, the compositor's —
/// lands on the glass 1:1. The loop estimates the offset between the source clock and the present
/// clock, and hands back a due time on the source's own timeline plus a cushion sized to the
/// measured jitter.
///
/// **Type-2 on purpose.** It tracks offset *and* per-frame rate, because two free-running crystals
/// produce a ramp and a proportional-only loop lags a ramp forever.
///
/// **It smooths the offset, never the timestamps.** The due time is `src_pts + offset + cushion`,
/// so genuine variation in the source's own cadence — a variable-rate renderer, an irregular
/// capture tick — passes straight through. Only the transport's contribution to `ready pts` is
/// filtered. Any change that makes due times more evenly spaced than the source is a bug, not an
/// improvement; `preserves_source_cadence` is the test that says so.
///
/// **Domain-agnostic by construction.** A constant offset between clock domains (monotonic vs
/// realtime) is absorbed by the offset estimator, so a caller feeds `ready_ns` and reads `due_ns`
/// in ONE domain and needs no clock conversion anywhere in this path. Suspend/resume breaks the
/// constant — that is a discontinuity, and [`reset`](Self::reset) covers it.
///
/// Prior art is ordinary and old: MPEG-2 TS PCR recovery and RTP playout scheduling (RFC 3550
/// §6.4.1 carries the jitter estimator this MAD mirrors).
#[derive(Debug, Clone)]
pub struct CadenceClock {
tuning: CadenceTuning,
/// `ready src_pts`, smoothed. Absorbs the clock-domain constant.
offset_ns: i64,
/// Per-frame drift of that offset — the integral term.
skew_ns: i64,
/// EMA of |residual|, the cushion's input.
mad_ns: i64,
/// `None` until the first sample anchors the loop.
last_pts_ns: Option<u64>,
/// Last frame interval seen, so [`cushion_ns`](Self::cushion_ns) can apply its ceiling.
frame_interval_ns: i64,
health: CadenceHealth,
}
impl CadenceClock {
pub fn new(tuning: CadenceTuning) -> CadenceClock {
CadenceClock {
tuning,
offset_ns: 0,
skew_ns: 0,
mad_ns: 0,
last_pts_ns: None,
frame_interval_ns: 0,
health: CadenceHealth::default(),
}
}
/// Force a re-anchor on the next sample. Call on every discontinuity the client already knows
/// about: reanchor, codec rebuild, surface recreate, jump-to-live, resume.
pub fn reset(&mut self) {
self.last_pts_ns = None;
self.skew_ns = 0;
// `mad_ns` deliberately SURVIVES. It describes the link, not the stream, and a cushion
// that collapsed to its floor at every rebuild would spend the next few hundred frames
// presenting late — the exact failure the cushion exists to prevent.
}
/// Fold one presentable frame and return when it is due, in the present clock domain.
///
/// `ready_ns` is when the frame became presentable; `frame_interval_ns` is the nominal source
/// interval and the cushion's ceiling.
///
/// The result **may be earlier than `ready_ns`** — that is a late frame, and the caller's
/// contract is "already due ⇒ present at the next opportunity", never "drag the grid back to
/// now". Clamping here would quietly turn every late frame into a fresh anchor.
pub fn due_ns(&mut self, src_pts_ns: u64, ready_ns: i64, frame_interval_ns: i64) -> i64 {
self.frame_interval_ns = frame_interval_ns;
self.health.frames += 1;
let pts = src_pts_ns as i64;
let raw = ready_ns - pts;
let anchored = match self.last_pts_ns {
// Source time going BACKWARDS, or a gap so long the estimate cannot be trusted to
// have tracked across it: re-anchor rather than slew for seconds.
Some(last)
if src_pts_ns < last || src_pts_ns - last > self.tuning.reanchor_gap_ns as u64 =>
{
false
}
Some(_) => true,
None => false,
};
if anchored {
// Advance the estimate one frame on the rate term, then correct it by a bounded
// fraction of what the new sample says.
self.offset_ns = self.offset_ns.saturating_add(self.skew_ns);
let err = (raw - self.offset_ns)
.clamp(-self.tuning.error_clamp_ns, self.tuning.error_clamp_ns);
self.offset_ns = self
.offset_ns
.saturating_add(shr_toward_zero(err, self.tuning.offset_shift));
self.skew_ns = self
.skew_ns
.saturating_add(shr_toward_zero(err, self.tuning.skew_shift));
let dev = err.abs() - self.mad_ns;
self.mad_ns += shr_toward_zero(dev, self.tuning.jitter_shift);
} else {
self.offset_ns = raw;
self.skew_ns = 0;
self.health.reanchors += 1;
}
self.last_pts_ns = Some(src_pts_ns);
let due = pts
.saturating_add(self.offset_ns)
.saturating_add(self.cushion_ns());
if due < ready_ns {
self.health.late += 1;
}
self.publish();
due
}
/// A frame whose timestamp is not on the source cadence — a repeat the host re-anchored at
/// submit, or a stamp its plausibility gate replaced with "now". Those samples do not lie on
/// the source's timeline, and folding them in would drag the offset estimate toward "now"
/// exactly when the stream is idle and the estimate matters most.
///
/// Returns a due time from the CURRENT estimate, leaving offset, skew and jitter untouched:
/// the frame is simply due once it is ready, cushioned like any other.
pub fn note_off_cadence(&mut self, ready_ns: i64, frame_interval_ns: i64) -> i64 {
self.frame_interval_ns = frame_interval_ns;
ready_ns.saturating_add(self.cushion_ns())
}
pub fn jitter_ns(&self) -> i64 {
self.mad_ns
}
/// How far past the estimate a frame is held, to absorb the measured jitter.
///
/// The one-frame-interval ceiling is an INVARIANT, not a tunable: a cushion past a whole frame
/// buys latency for smoothness the source cannot supply, and at that point the honest fix is a
/// deeper buffer the user asked for, not a loop quietly holding frames.
pub fn cushion_ns(&self) -> i64 {
let den = self.tuning.cushion_den.max(1) as i64;
let want = self.mad_ns.saturating_mul(self.tuning.cushion_num as i64) / den;
let ceiling = if self.frame_interval_ns > 0 {
self.frame_interval_ns
} else {
i64::MAX
};
want.clamp(self.tuning.cushion_floor_ns.min(ceiling), ceiling)
}
pub fn health(&self) -> CadenceHealth {
let mut h = self.health;
h.offset_ns = self.offset_ns;
h.skew_ns = self.skew_ns;
h.jitter_ns = self.mad_ns;
h.cushion_ns = self.cushion_ns();
h
}
fn publish(&mut self) {
self.health.offset_ns = self.offset_ns;
self.health.skew_ns = self.skew_ns;
self.health.jitter_ns = self.mad_ns;
}
}
/// Arithmetic shift that rounds toward ZERO, so a negative residual is damped by exactly as much
/// as its positive twin. A plain `>>` rounds toward −∞, which biases a loop that spends its whole
/// life within a few nanoseconds of zero error.
const fn shr_toward_zero(v: i64, shift: u8) -> i64 {
if v < 0 {
-((-v) >> shift)
} else {
v >> shift
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -136,6 +391,336 @@ mod tests {
const P: i64 = 8_333_333; // 120 Hz in ns
const P_US: u64 = 8_333; // …and in µs, the sample unit
// ---- CadenceClock (design/presenter-cadence-rework-implementation-plan.md §2.3) --------
/// A source stamping realtime, played out by a client whose present clock is monotonic and
/// therefore a whole different era. The loop must never need to be told about this.
const PTS0: u64 = 1_786_000_000_000_000_000;
const DOMAIN: i64 = -1_785_000_000_000_000_000;
/// Transport + decode: what `ready pts` sits at once the domain is taken out.
const DELAY: i64 = 12_000_000;
/// Deterministic LCG in ±spread around zero — no OS randomness in tests.
struct Lcg(u64);
impl Lcg {
fn noise(&mut self, spread_ns: i64) -> i64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
if spread_ns == 0 {
return 0;
}
((self.0 >> 33) as i64 % (2 * spread_ns)) - spread_ns
}
}
fn pts_at(k: i64) -> u64 {
(PTS0 as i64 + k * P) as u64
}
/// Run `n` frames of a well-behaved 120 Hz source and hand back the clock.
fn settled(n: i64, spread_ns: i64) -> CadenceClock {
let mut c = CadenceClock::new(CadenceTuning::snapping());
let mut rng = Lcg(7);
for k in 0..n {
let ready = pts_at(k) as i64 + DOMAIN + DELAY + rng.noise(spread_ns);
c.due_ns(pts_at(k), ready, P);
}
c
}
#[test]
fn settles_from_cold() {
let c = settled(400, 1_000_000);
let err = c.health().offset_ns - (DOMAIN + DELAY);
assert!(
err.abs() < 500_000,
"offset must converge on the true transport delay, off by {err} ns"
);
assert_eq!(c.health().reanchors, 1, "only the cold start anchors");
}
/// The type-2 property, and the reason the loop carries a rate term at all: two free-running
/// crystals produce a RAMP, and a proportional-only loop lags a ramp forever. Asserted against
/// its own type-1 twin so the difference is the measurement, not a threshold I chose.
#[test]
fn tracks_a_clock_ramp() {
const RAMP: i64 = 400; // ns per frame ≈ 48 ppm, an ordinary crystal pair
let run = |tuning: CadenceTuning| -> i64 {
let mut c = CadenceClock::new(tuning);
let mut last_err = 0;
for k in 0..4_000i64 {
let ready = pts_at(k) as i64 + DOMAIN + DELAY + k * RAMP;
c.due_ns(pts_at(k), ready, P);
last_err = (ready - pts_at(k) as i64) - c.health().offset_ns;
}
last_err.abs()
};
let type2 = run(CadenceTuning::snapping());
// The same loop with its integral gain switched off: a shift this large truncates every
// residual to zero, which is exactly "proportional only".
let type1 = run(CadenceTuning {
skew_shift: 63,
..CadenceTuning::snapping()
});
assert!(
type2 * 4 < type1,
"a rate term must beat proportional-only on a ramp: {type2} ns vs {type1} ns"
);
assert!(type2 < 3_000, "steady-state ramp error {type2} ns");
}
#[test]
fn rejects_a_single_outlier() {
let mut c = settled(400, 200_000);
let before = c.health().offset_ns;
// One frame arrives half a second late — a stall, not a new operating point.
let k = 400;
c.due_ns(
pts_at(k),
pts_at(k) as i64 + DOMAIN + DELAY + 500_000_000,
P,
);
let moved = (c.health().offset_ns - before).abs();
// The clamped correction, plus the one frame of rate the loop advances by regardless —
// that advance is the estimate doing its job, not the outlier moving it.
let t = CadenceTuning::snapping();
let bound = (t.error_clamp_ns >> t.offset_shift) + c.health().skew_ns.abs();
assert!(
moved <= bound,
"one outlier moved the estimate {moved} ns, past the clamp's {bound} ns"
);
}
#[test]
fn reanchors_on_a_gap() {
let mut c = settled(400, 200_000);
let anchors = c.health().reanchors;
// The stream was paused for two seconds; the estimate cannot have tracked across that.
let far = pts_at(400) + 2_000_000_000;
let ready = far as i64 + DOMAIN + DELAY + 4_000_000;
c.due_ns(far, ready, P);
assert_eq!(c.health().reanchors, anchors + 1);
assert_eq!(
c.health().offset_ns,
ready - far as i64,
"a re-anchor adopts the new sample outright rather than slewing to it"
);
}
#[test]
fn reanchors_on_regression() {
let mut c = settled(400, 200_000);
let anchors = c.health().reanchors;
let back = pts_at(200); // source timestamps went backwards
c.due_ns(back, back as i64 + DOMAIN + DELAY, P);
assert_eq!(c.health().reanchors, anchors + 1);
}
/// A due time in the past is returned AS IS. Clamping it to `ready_ns` would quietly turn
/// every late frame into a fresh anchor, which is how an arrival-driven presenter behaves —
/// the thing this clock exists to stop being.
#[test]
fn late_frame_returns_past_due() {
let mut c = settled(400, 200_000);
let k = 400;
let ready = pts_at(k) as i64 + DOMAIN + DELAY + 30_000_000; // 30 ms late
let due = c.due_ns(pts_at(k), ready, P);
assert!(
due < ready,
"a frame that arrived 30 ms late must read as already due"
);
assert_eq!(c.health().late, 1);
}
#[test]
fn off_cadence_does_not_move_the_loop() {
let mut c = settled(400, 500_000);
let before = c.health();
let due = c.note_off_cadence(1_000_000, P);
let after = c.health();
assert_eq!(before.offset_ns, after.offset_ns);
assert_eq!(before.skew_ns, after.skew_ns);
assert_eq!(before.jitter_ns, after.jitter_ns);
assert_eq!(
before.frames, after.frames,
"and it is not a cadence sample"
);
assert_eq!(due, 1_000_000 + c.cushion_ns());
}
/// One domain in, same domain out: shifting the whole present-side trace by an arbitrary
/// constant must change every due time by exactly that constant and nothing else. This is
/// what lets each client feed its own clock without a conversion in the path.
#[test]
fn domain_offset_is_absorbed() {
const SHIFT: i64 = 987_654_321_000;
let run = |extra: i64| -> Vec<i64> {
let mut c = CadenceClock::new(CadenceTuning::snapping());
let mut rng = Lcg(11);
(0..300i64)
.map(|k| {
let ready = pts_at(k) as i64 + DOMAIN + DELAY + extra + rng.noise(2_000_000);
c.due_ns(pts_at(k), ready, P)
})
.collect()
};
let a = run(0);
let b = run(SHIFT);
for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
assert_eq!(y - x, SHIFT, "frame {i} shifted by {} not {SHIFT}", y - x);
}
}
/// The invariant that separates this from a metronome: a source that genuinely runs at an
/// irregular rate is REPRODUCED, not evened out. Anything that made these due spacings more
/// uniform than the source's own would be a bug.
#[test]
fn preserves_source_cadence() {
let mut c = CadenceClock::new(CadenceTuning::snapping());
// A deliberately lumpy source: alternating short and long frames.
let spacings: Vec<i64> = (0..300)
.map(|k| if k % 2 == 0 { P / 2 } else { P * 3 / 2 })
.collect();
let mut pts = PTS0;
let mut dues = Vec::new();
let mut ptss = Vec::new();
let mut rng = Lcg(13);
for &s in &spacings {
pts = (pts as i64 + s) as u64;
let ready = pts as i64 + DOMAIN + DELAY + rng.noise(500_000);
ptss.push(pts as i64);
dues.push(c.due_ns(pts, ready, P));
}
// Compare the back half, once the loop has settled.
for i in 200..dues.len() {
let d_due = dues[i] - dues[i - 1];
let d_pts = ptss[i] - ptss[i - 1];
assert!(
(d_due - d_pts).abs() < 200_000,
"due spacing {d_due} must follow the source's {d_pts}"
);
}
}
#[test]
fn cushion_respects_ceiling() {
let mut c = CadenceClock::new(CadenceTuning::free_running());
let mut rng = Lcg(17);
// Jitter far wider than a frame — the cushion must still never exceed one interval.
for k in 0..500i64 {
let ready = pts_at(k) as i64 + DOMAIN + DELAY + rng.noise(40_000_000);
c.due_ns(pts_at(k), ready, P);
assert!(
c.cushion_ns() <= P,
"cushion {} ns exceeded the frame interval",
c.cushion_ns()
);
}
assert!(
c.jitter_ns() > P,
"the harness must actually have stressed it"
);
}
// ---- Offline sim (§2.4): the real clock, replayed, against today's rule ----------------
//
// R7, the phase-lock v3 lesson: the harness imports the REAL type. A paraphrase once
// "confirmed" a non-bug and cost a session.
/// Judder as WP1 defines it: round each consecutive present spacing to whole panel periods,
/// then report the ‰ of intervals that are not the modal count.
fn judder_permille(presents: &[i64], panel_ns: i64) -> u32 {
let counts: Vec<i64> = presents
.windows(2)
.map(|w| (w[1] - w[0] + panel_ns / 2) / panel_ns)
.collect();
if counts.is_empty() {
return 0;
}
let mode = *counts
.iter()
.max_by_key(|c| counts.iter().filter(|x| x == c).count())
.unwrap();
let off = counts.iter().filter(|c| **c != mode).count();
(off * 1000 / counts.len()) as u32
}
/// Turn a series of target instants into the refreshes a frame actually reaches glass on.
///
/// Newest-wins, which is what both presenters really do: a frame whose slot is already
/// claimed REPLACES the one aimed there rather than being delayed to the next refresh.
/// Delaying it instead would ratchet — one clamp puts the sequence permanently ahead of its
/// own targets and every later frame clamps too, producing a flawless metronome out of an
/// arbitrarily jittery input, and scoring zero judder for both rules.
fn present_slots(targets: &[i64], panel_ns: i64) -> Vec<i64> {
let mut out: Vec<i64> = Vec::new();
for &t in targets {
let slot = (t + panel_ns - 1) / panel_ns * panel_ns;
if out.last().is_none_or(|&last| slot > last) {
out.push(slot);
}
}
out
}
#[test]
fn the_clock_beats_arrival_presentation_on_a_jittery_link() {
// ±6 ms — the 2026-08-15 field shape, where KWin's screencast arrival offsets ran
// 0.118.22 ms against an 8.33 ms period. Jitter much narrower than the panel period is
// the case snapping absorbs on its own (and, at a lucky grid phase, absorbs entirely),
// which is precisely why the reported host is the one worth simulating.
const JITTER: i64 = 6_000_000;
let mut c = CadenceClock::new(CadenceTuning::snapping());
let mut rng = Lcg(23);
let (mut arrival, mut cadence) = (Vec::new(), Vec::new());
for k in 0..1_200i64 {
let ready = pts_at(k) as i64 + DOMAIN + DELAY + rng.noise(JITTER);
let due = c.due_ns(pts_at(k), ready, P);
if k > 200 {
// Today: aim at the frame the moment it is decoded. With the clock: aim at its
// due time, and never before the frame exists.
arrival.push(ready);
cadence.push(ready.max(due));
}
}
let ja = judder_permille(&present_slots(&arrival, P), P);
let jc = judder_permille(&present_slots(&cadence, P), P);
// The expected-gain figure §2.4 asks this harness to produce. `cargo test -- --nocapture`.
println!(
"sim: arrival {ja}‰ → cadence {jc}‰ (cushion {} ns)",
c.cushion_ns()
);
assert!(
jc < ja / 2,
"source-timestamp playout must materially beat arrival: {jc}‰ vs {ja}‰"
);
}
/// …and it must NOT "win" by flattening a source that is genuinely uneven — the same harness,
/// a variable-rate source, and the clock is expected to reproduce its lumpiness.
#[test]
fn the_sim_does_not_reward_flattening_a_variable_source() {
let mut c = CadenceClock::new(CadenceTuning::snapping());
let mut rng = Lcg(29);
let mut pts = PTS0;
let (mut dues, mut ptss) = (Vec::new(), Vec::new());
for k in 0..600i64 {
// A renderer alternating 60 and 120 fps work — real, and not a defect.
pts = (pts as i64 + if k % 3 == 0 { 2 * P } else { P }) as u64;
let ready = pts as i64 + DOMAIN + DELAY + rng.noise(500_000);
ptss.push(pts as i64);
dues.push(c.due_ns(pts, ready, P));
}
let jd = judder_permille(&dues[300..], P);
let jp = judder_permille(&ptss[300..], P);
assert_eq!(
jd, jp,
"the due-time cadence must score exactly what the source's own does"
);
}
#[test]
fn identical_samples_are_fully_coherent() {
let (mean, coh) = circular_latch(&[4_000; 16], P).unwrap();
+14
View File
@@ -72,6 +72,20 @@ pub trait AudioCapturer: Send {
/// reopen.
fn next_chunk(&mut self) -> Result<Vec<f32>>;
/// [`next_chunk`](Self::next_chunk) with a caller-chosen upper bound on the wait.
///
/// The encode loop owes the wire a frame every 5 ms whether or not capture has anything to
/// say, and blocking here for as long as the capturer feels like is what made a capture hole
/// cost far more than the audio it swallowed: nothing left the host for the hole's whole
/// duration, so the client's de-jitter ring drained, underran and de-primed over a gap it
/// could otherwise have ridden through (WP-B1). Expiry returns an **empty** chunk — the same
/// "no samples right now" the idle timeout reports, and equally not an error.
///
/// Backends with no bound of their own just delegate; they simply wake less precisely.
fn next_chunk_within(&mut self, _budget: std::time::Duration) -> Result<Vec<f32>> {
self.next_chunk()
}
/// The interleaved channel count this capturer delivers (what it was opened with).
fn channels(&self) -> u32 {
CHANNELS as u32
@@ -97,6 +97,12 @@ impl FightDamper {
/// How often the capture loop reports its vitals (WP0.2).
pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30);
/// Shortest callback-to-callback delta that can be called a gap, whatever the quantum. At the
/// 5 ms quantum we ask for, `2 × quantum` would be 10 ms anyway; this floor is what stops a
/// graph running an even smaller quantum (the 2026-08-15 field host negotiated 128 frames =
/// 2.7 ms) from scoring ordinary scheduling noise as a hole.
const GAP_FLOOR: Duration = Duration::from_millis(10);
/// One reporting window's worth of capture vitals.
///
/// The point is to make three states that used to look identical in a log tell themselves apart: a
@@ -120,6 +126,18 @@ pub(crate) struct CaptureStats {
/// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of
/// everything after it.
pub(crate) dropped_chunks: u64,
/// Windows in which the callback simply did not run on time (WP-A2). `delivered_pct` proves
/// audio is missing but structurally cannot say HOW: one 2 s hole and three hundred 8 ms
/// hiccups produce the same percentage and want completely different answers (a device or
/// graph fault vs. a scheduling fault). The 2026-08-15 field log sat at 8497 % for 24
/// minutes of loud gameplay with `dropped_chunks=0` and no way to tell those apart.
pub(crate) gaps: u64,
/// The largest of those, µs. Reported in ms; kept in µs so a sub-ms threshold is expressible.
pub(crate) max_gap_us: u64,
/// Callbacks that ran but carried nothing — no buffer to dequeue, no `datas`, no mapped
/// memory. Every one of these used to `return` silently, so a stream that fired its callback
/// on time and handed us nothing looked identical to a stream nobody was feeding.
pub(crate) missed_dequeues: u64,
}
impl CaptureStats {
@@ -135,6 +153,36 @@ impl CaptureStats {
}
}
/// Score one callback arrival against the previous one.
///
/// `since_last` is `None` for the first callback of a stream — and, deliberately, for the
/// first after a state transition: the caller drops its stamp when the stream pauses, so a
/// legitimately Paused span is not scored as one enormous hole. (The Paused↔Streaming flaps
/// around a format renegotiation stay visible as the state DEBUG lines next to a small
/// post-resume gap, which is the honest reading of what happened.)
///
/// `quantum` is the NEGOTIATED buffer duration, not the one we asked for: a graph handing us
/// 21.3 ms buffers is not gapping when its callbacks are 21.3 ms apart — it is doing exactly
/// what it negotiated, and the quantum warning above already said so.
pub(crate) fn observe_callback(&mut self, since_last: Option<Duration>, quantum: Duration) {
let Some(delta) = since_last else { return };
if delta > (quantum * 2).max(GAP_FLOOR) {
self.gaps += 1;
// The MISSING audio, not the callback delta: one quantum of that delta is the buffer
// we were legitimately handed. Reporting the delta would inflate every gap by the
// quantum and — worse — mean something different from the Windows feed, which sizes
// its holes from the device position and so reports missing audio by construction.
self.max_gap_us = self
.max_gap_us
.max(delta.saturating_sub(quantum).as_micros() as u64);
}
}
/// The window's worst gap in whole ms — the unit the log line and the field reports speak.
pub(crate) fn max_gap_ms(&self) -> u64 {
self.max_gap_us / 1_000
}
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
/// -inf so the log line stays parseable.
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
@@ -151,6 +199,79 @@ impl CaptureStats {
}
}
/// How long a capture hole may run before the wire starts covering it. Two protocol frames: long
/// enough that ordinary quantum jitter never trips it, short enough that the client's ring never
/// notices the hole.
pub(crate) const INFILL_AFTER: Duration = Duration::from_millis(2 * FRAME_MS as u64);
/// How much silence one hole may be covered with. Past this the host is not glitching, it is
/// QUIET — a desktop between games is legitimately silent for hours and paying a few kbps to keep
/// saying so is absurd — so the wire stops, which is exactly the behaviour that shipped before.
pub(crate) const INFILL_MAX: Duration = Duration::from_millis(500);
/// One protocol audio frame — the wire's unit, and the granularity infill works in.
const FRAME_MS: u32 = punktfunk_core::audio::FRAME_MS;
/// What the wire owes for the slot that is due now.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Infill {
/// Nothing: audio is flowing, or the hole is still too young to be worth covering.
Wait,
/// One frame of silence, on the pacer's schedule and continuous with what came before.
Silence,
/// The budget is spent. Say nothing — and the next real chunk begins a NEW continuity.
Quiet,
}
/// Whether, and for how long, the wire covers a capture hole with silence (WP-B1).
///
/// A hole used to cost far more than the audio it swallowed. `audio_thread` blocked in
/// `next_chunk` for its whole duration, so nothing at all left the host: the client's de-jitter
/// ring drained, underran, de-primed, and then had to re-prime — turning a 30 ms hole into a much
/// longer audible artifact, and doing it 316 % of the time on the 2026-08-15 field host. Silence
/// on the same 5 ms schedule, with continuous `seq` and pts, keeps that ring fed and its playout
/// anchored, so what the listener loses shrinks to exactly the audio that was genuinely missing.
///
/// Time is passed IN, so the policy is pure and its tests run on every platform.
#[derive(Default)]
pub(crate) struct InfillPolicy {
/// Silence already sent for the open hole. Denominated in TIME rather than in frames or
/// callbacks, which is the recorded lesson from the client's de-prime fuse: a count there made
/// an iPad give up three times sooner than a Mac for no reason anyone intended.
filled_ms: u32,
/// Latched once a hole outlives the budget and the wire falls silent.
broke: bool,
}
impl InfillPolicy {
/// Decide the slot that is due now. Call EXACTLY once per due frame — it consumes budget.
pub(crate) fn decide(&mut self, since_last_chunk: Duration) -> Infill {
if since_last_chunk < INFILL_AFTER {
return Infill::Wait;
}
if self.filled_ms as u64 >= INFILL_MAX.as_millis() as u64 {
self.broke = true;
return Infill::Quiet;
}
self.filled_ms += FRAME_MS;
Infill::Silence
}
/// True once the budget is spent, so the caller can go back to blocking for real audio
/// instead of waking every few milliseconds to decide to stay quiet.
pub(crate) fn exhausted(&self) -> bool {
self.filled_ms as u64 >= INFILL_MAX.as_millis() as u64
}
/// A real chunk arrived. Returns whether the hole it closed BROKE continuity — the wire went
/// silent across it, so the redundancy predecessor and any partial frame straddling the hole
/// both describe audio from before a discontinuity, and neither may be spliced onto what
/// comes next.
pub(crate) fn chunk_arrived(&mut self) -> bool {
self.filled_ms = 0;
std::mem::take(&mut self.broke)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -257,4 +378,151 @@ mod tests {
let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000);
assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}");
}
/// The 5 ms quantum we ask for — the shape all three gap tests are measured against.
const Q: Duration = Duration::from_millis(5);
/// The product gap the 2026-08-15 field log left open, closed: `delivered_pct` alone reports
/// the SAME 93 % for one 2 s hole and for three hundred 8 ms hiccups, and those are different
/// faults with different fixes. The counters have to separate them without a second log.
#[test]
fn gap_accounting_tells_one_long_hole_from_many_short_ones() {
let mut one = CaptureStats::default();
one.observe_callback(None, Q); // first callback of the stream — nothing to compare to
one.observe_callback(Some(Duration::from_secs(2)), Q);
assert_eq!(one.gaps, 1);
// Two seconds between callbacks, one quantum of which was audio we were handed.
assert_eq!(one.max_gap_ms(), 2_000 - Q.as_millis() as u64);
// …versus three hundred 8 ms holes, each arriving as a 13 ms callback delta.
let mut many = CaptureStats::default();
for _ in 0..300 {
many.observe_callback(Some(Q + Duration::from_millis(8)), Q);
}
assert_eq!(many.gaps, 300);
assert_eq!(many.max_gap_ms(), 8);
// Both shapes lose comparable audio; only the counters tell them apart.
assert!(
one.max_gap_ms() > many.max_gap_ms() * 100,
"the discriminator is the SHAPE, not the total"
);
}
/// A stream delivering exactly what it negotiated is never a gap — including the clamped
/// 21.3 ms quantum a VM's `default.clock.min-quantum` forces, which would otherwise score a
/// gap on every single callback and bury the real ones.
#[test]
fn a_negotiated_cadence_is_never_a_gap() {
let mut s = CaptureStats::default();
for _ in 0..100 {
s.observe_callback(Some(Duration::from_micros(5_100)), Q); // 5 ms + jitter
}
assert_eq!(s.gaps, 0, "ordinary jitter at the negotiated quantum");
let clamped = Duration::from_micros(21_333); // 1024 frames @ 48 kHz
let mut vm = CaptureStats::default();
for _ in 0..100 {
vm.observe_callback(Some(clamped), clamped);
}
assert_eq!(vm.gaps, 0, "a clamped quantum is slow, not gapping");
// …and a real hole on that same graph still scores.
vm.observe_callback(Some(Duration::from_millis(200)), clamped);
assert_eq!(vm.gaps, 1);
}
/// Drive one hole from the moment it opens until the policy gives up on it, the way the
/// encode loop does: one decision per due frame slot.
fn cover_a_hole(p: &mut InfillPolicy) -> usize {
let mut silence = 0usize;
let mut open = INFILL_AFTER;
loop {
match p.decide(open) {
Infill::Silence => {
silence += 1;
open += Duration::from_millis(FRAME_MS as u64);
}
Infill::Quiet => return silence,
Infill::Wait => unreachable!("the hole is open — {open:?} is past INFILL_AFTER"),
}
assert!(silence < 10_000, "the budget must be finite");
}
}
/// The wire covers a hole for exactly as long as the budget allows, and then admits the host
/// is simply quiet. Both halves matter: without the first, a 30 ms hole costs the client a
/// de-prime and a re-prime; without the second, an idle desktop pays for silence datagrams
/// forever.
#[test]
fn infill_covers_a_hole_and_then_admits_the_host_is_quiet() {
let mut p = InfillPolicy::default();
// Ordinary quantum jitter, not a hole — nothing owed.
assert_eq!(p.decide(Duration::ZERO), Infill::Wait);
assert_eq!(
p.decide(INFILL_AFTER - Duration::from_millis(1)),
Infill::Wait
);
let silence = cover_a_hole(&mut p);
assert_eq!(
silence as u64 * FRAME_MS as u64,
INFILL_MAX.as_millis() as u64,
"the wire must cover exactly the budget, in frames of {FRAME_MS} ms"
);
assert!(p.exhausted(), "…and then stop asking");
}
/// A stream that is flowing must never synthesize anything — this policy is invisible until
/// something is actually wrong.
#[test]
fn a_flowing_stream_never_infills() {
let mut p = InfillPolicy::default();
for _ in 0..1_000 {
assert_eq!(
p.decide(Duration::from_millis(FRAME_MS as u64)),
Infill::Wait
);
}
assert!(!p.exhausted());
assert!(!p.chunk_arrived(), "no hole means no discontinuity");
}
/// Only a hole the wire could NOT cover breaks continuity. The distinction is the whole point
/// of the budget: across a covered hole `seq` and pts never broke, so the redundancy
/// predecessor still describes the frame before this one and the client can keep using it.
#[test]
fn only_an_uncovered_hole_breaks_continuity() {
let mut covered = InfillPolicy::default();
for k in 0..20u64 {
covered.decide(INFILL_AFTER + Duration::from_millis(FRAME_MS as u64 * k));
}
assert!(
!covered.chunk_arrived(),
"a covered hole is continuous — the client heard silence, not a splice"
);
let mut lost = InfillPolicy::default();
cover_a_hole(&mut lost);
assert!(
lost.chunk_arrived(),
"past the budget the wire went quiet, so nothing before the hole may be spliced on"
);
// …and the next hole starts from a clean budget rather than an exhausted one.
assert!(!lost.exhausted());
assert_eq!(cover_a_hole(&mut lost) as u64 * FRAME_MS as u64, 500);
}
/// A deliberate pause is not a hole. The caller drops its stamp across a state transition, so
/// the span reaches us as `None` — without that rule every Paused↔Streaming flap (three of
/// them in minute 1 of the field log, around each format renegotiation) would report a gap
/// the size of the pause and drown the sub-10 ms ones that actually matter.
#[test]
fn a_paused_span_is_not_scored() {
let mut s = CaptureStats::default();
s.observe_callback(Some(Duration::from_millis(5)), Q);
s.observe_callback(None, Q); // resumed: the pause spanned an unknowable amount of time
s.observe_callback(Some(Duration::from_millis(5)), Q);
assert_eq!(s.gaps, 0);
assert_eq!(s.max_gap_ms(), 0);
}
}
+86 -4
View File
@@ -162,7 +162,11 @@ impl Drop for PwAudioCapturer {
impl AudioCapturer for PwAudioCapturer {
fn next_chunk(&mut self) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(Duration::from_secs(5)) {
self.next_chunk_within(Duration::from_secs(5))
}
fn next_chunk_within(&mut self, budget: Duration) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(budget) {
Ok(c) => Ok(c),
// A quiet sink (paused game, idle desktop) is NOT a failure — return an empty chunk so the
// caller keeps the capturer alive. Only a dead capture thread is an Err (→ caller reopens).
@@ -740,6 +744,18 @@ fn pw_thread(
// default election against real hardware; session routing comes from the
// stream_sink claim, not from priority.
"priority.session" => "50",
// Never let the session manager suspend this node (WP-B2). The 2026-08-15
// field log shows three `audio format negotiated` lines in the first minute,
// each wrapped in a Paused↔Streaming flap: Wine churns its audio device at
// launch, the sink goes briefly unused, WirePlumber suspends it on its idle
// timeout, and the next app resumes it — and every one of those round trips
// is a real hole in a stream someone is listening to.
//
// Deliberately NOT `node.always-process`: that keeps the node SCHEDULED with
// nothing connected, so a host sitting between sessions would run this
// callback two hundred times a second forever (risk R5). Disabling the
// suspend keeps the node available without asking anyone to drive it.
"session.suspend-timeout-seconds" => "0",
};
p.insert(*pw::keys::NODE_NAME, name.as_str());
p
@@ -772,6 +788,19 @@ fn pw_thread(
/// never again — the one number that identifies a clamped quantum, invisible on every
/// subsequent open (including every reopen after a device change).
reported_quantum: bool,
/// When the callback last ran (WP-A2), so its CADENCE can be scored and not just its
/// content. Cleared across a state transition — a deliberate Paused span must not
/// read as one enormous hole. Lives here rather than in `stats` because the stats
/// reset every reporting window and the cadence does not.
last_cb: Option<std::time::Instant>,
/// The quantum actually negotiated, which is what a gap is measured against. Seeded
/// with the one we ASK for, and corrected on the first callback that carries data —
/// on a graph that clamped us to 1024 frames, 21.3 ms between callbacks is the deal
/// we got, not a fault.
quantum: Duration,
/// The format currently negotiated, so a renegotiation that resolves to the SAME one
/// can be told from a real change (WP-B2).
negotiated: Option<(spa::param::audio::AudioFormat, u32, u32)>,
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
active: Arc<AtomicBool>,
@@ -782,14 +811,24 @@ fn pw_thread(
stats: Default::default(),
last_stats: std::time::Instant::now(),
reported_quantum: false,
last_cb: None,
quantum: Duration::from_micros(
CAPTURE_QUANTUM_FRAMES as u64 * 1_000_000 / SAMPLE_RATE as u64,
),
negotiated: None,
active,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
move |_s, ud, old, new| {
tracing::debug!(?old, ?new, "pipewire audio stream state");
// Any transition ends the cadence we were measuring: the span across a
// Paused↔Streaming flap is not a gap in delivery, it is a gap in the stream
// existing. Scoring it would report one huge hole per renegotiation and bury
// the sub-10 ms ones the field log is actually about (WP-A2).
ud.last_cb = None;
// A stream error is unrecoverable for this instance — exit so the sessions'
// reopen path builds a fresh one (same contract as the core-error path above).
if matches!(new, pw::stream::StreamState::Error(_)) {
@@ -797,13 +836,29 @@ fn pw_thread(
}
}
})
.param_changed(move |_stream, _tx, id, param| {
.param_changed(move |_stream, ud, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// Renegotiating to the format we already had is the graph resuming us, not
// the stream changing (WP-B2): the field log's minute-1 burst was three of
// these, which read as three format changes and were none. Say which it was
// — the flap itself stays visible in the state DEBUG lines and in A2's gap
// counters, where it belongs.
let now = (info.format(), info.rate(), info.channels());
if ud.negotiated == Some(now) {
tracing::debug!(
format = ?now.0,
rate = now.1,
channels = now.2,
"audio format renegotiated, unchanged (the graph resumed our sink)"
);
return;
}
ud.negotiated = Some(now);
// `stream_sink` says WHICH source this format describes, and that changes how
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
// format apps render into and the desktop mix cannot have been narrowed before
@@ -824,11 +879,22 @@ fn pw_thread(
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// Score the ARRIVAL before anything can make us return: a callback that ran
// and carried nothing still proves the callback ran, and that is a different
// fault from one that never ran at all. Stamped first, so the two are
// counted independently (WP-A2).
let now = std::time::Instant::now();
let since_last = ud.last_cb.map(|t| now.duration_since(t));
ud.last_cb = Some(now);
ud.stats.observe_callback(since_last, ud.quantum);
let Some(mut buffer) = stream.dequeue_buffer() else {
ud.stats.missed_dequeues += 1;
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
ud.stats.missed_dequeues += 1;
return;
}
let d = &mut datas[0];
@@ -836,8 +902,12 @@ fn pw_thread(
let c = d.chunk();
(c.offset() as usize, c.size() as usize)
};
let Some(buf) = d.data() else { return };
let Some(buf) = d.data() else {
ud.stats.missed_dequeues += 1;
return;
};
if offset > buf.len() {
ud.stats.missed_dequeues += 1;
return;
}
let region = &buf[offset..(offset + size).min(buf.len())];
@@ -854,6 +924,12 @@ fn pw_thread(
// whole field investigation to find; it should cost one log line.
let frames = n / (ud.channels.max(1) as usize);
let want = CAPTURE_QUANTUM_FRAMES as usize;
// What a gap is measured against from here on — see `CapUd::quantum`.
if frames > 0 {
ud.quantum = Duration::from_micros(
frames as u64 * 1_000_000 / SAMPLE_RATE as u64,
);
}
if frames > want {
tracing::warn!(
requested_frames = want,
@@ -910,6 +986,12 @@ fn pw_thread(
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
// The shape of whatever `delivered_pct` is short by (WP-A2): one
// long hole and three hundred short ones read the same in the
// percentage and mean entirely different things.
gaps = ud.stats.gaps,
max_gap_ms = ud.stats.max_gap_ms(),
missed_dequeues = ud.stats.missed_dequeues,
dropped_chunks = ud.stats.dropped_chunks,
"desktop audio capture"
);
@@ -117,7 +117,10 @@ impl Drop for WasapiLoopbackCapturer {
impl AudioCapturer for WasapiLoopbackCapturer {
fn next_chunk(&mut self) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(Duration::from_secs(5)) {
self.next_chunk_within(Duration::from_secs(5))
}
fn next_chunk_within(&mut self, budget: Duration) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(budget) {
Ok(c) => Ok(c),
// A quiet sink is NOT a failure — return an empty chunk so the caller keeps the capturer
// alive. Only a dead capture thread is an Err (→ caller reopens). Matches the Linux path.
@@ -185,6 +188,13 @@ const DEFAULT_CHECK_EVERY: Duration = Duration::from_secs(1);
const FIRST_OPEN_ATTEMPTS: u32 = 3;
/// Pause between first-open attempts (endpoint churn settles in well under a second).
const FIRST_OPEN_RETRY_PAUSE: Duration = Duration::from_secs(1);
/// How long the packet loop may go without a packet before the next `DATA_DISCONTINUITY` reads as
/// the endpoint having idled rather than as a capture hole (WP-A2). Classic loopback delivers
/// nothing at all while nothing renders and then flags the packet that resumes, so scoring that
/// flag unconditionally would charge a gap — sized by the quiet — to every notification sound on an
/// otherwise silent host. At the engine's ~10 ms period a whole second without a packet is far
/// past anything this loop can still tell apart from that.
const LOOPBACK_IDLE_AFTER: Duration = Duration::from_secs(1);
fn capture_thread(
tx: SyncSender<Vec<f32>>,
@@ -596,6 +606,16 @@ fn capture_once(
// and a permanent A/V offset, with nothing in any log).
let mut stats = CaptureStats::default();
let mut last_stats = Instant::now();
// WP-A2 — where the gap counters come from on Windows. `delivered_pct` proves audio is missing
// but not whether it went in one hole or three hundred, and no clock can answer that here: this
// is a POLLING loop over a tap that stops delivering entirely while the endpoint idles, so
// "time since the last data" — the rule the Linux capture callback uses — would score every
// quiet moment on the host as a hole. WASAPI says it outright instead: a packet flagged
// discontinuous is one the tap admits is not contiguous with the previous, and the device
// position it carries (`next_index` is where the next packet must start if nothing was lost)
// sizes the missing audio in the device's own clock, not in how late we happened to poll.
let mut last_packet: Option<Instant> = None;
let mut next_index: u64 = 0;
// WP2.4 — damping for the default-playback tug-of-war.
let mut fight = FightDamper::new(Instant::now());
loop {
@@ -611,9 +631,34 @@ fn capture_once(
Ok(Some(0)) | Ok(None) => break,
Ok(Some(_n)) => {
saw_packets = true;
capture_client
let before = bytes.len();
let info = capture_client
.read_from_device_to_deque(&mut bytes)
.context("read loopback")?;
let now = Instant::now();
// Judged BEFORE the stamp moves: a discontinuity on the first packet after a
// packet-less stretch is the tap waking up, which is what an idle endpoint
// does here, not a hole in anything that was playing.
let flowing =
last_packet.is_some_and(|t| now.duration_since(t) < LOOPBACK_IDLE_AFTER);
let frames = ((bytes.len() - before) / block_align) as u64;
if frames == 0 {
// Told a packet was ready, then handed none: the same fault the Linux twin
// counts when a callback runs with no buffer to dequeue, and equally
// invisible before — a tap spinning like this looked exactly like a quiet
// desktop.
stats.missed_dequeues += 1;
} else {
if info.flags.data_discontinuity && flowing {
stats.gaps += 1;
let lost = info.index.saturating_sub(next_index);
stats.max_gap_us = stats
.max_gap_us
.max(lost.saturating_mul(1_000_000) / SAMPLE_RATE as u64);
}
next_index = info.index.saturating_add(frames);
last_packet = Some(now);
}
}
Err(e) => return Err(anyhow!("get_next_packet_size: {e}")),
}
@@ -667,6 +712,14 @@ fn capture_once(
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
// The shape of whatever `delivered_pct` is short by (WP-A2): one long hole and
// three hundred short ones read the same in the percentage and mean entirely
// different things. Sourced from WASAPI's own discontinuity flag here rather than
// from the callback cadence the Linux twin measures, so an endpoint that idles and
// resumes is not among them — see [`LOOPBACK_IDLE_AFTER`].
gaps = stats.gaps,
max_gap_ms = stats.max_gap_ms(),
missed_dequeues = stats.missed_dequeues,
dropped_chunks = stats.dropped_chunks,
"desktop audio capture"
);
+97 -27
View File
@@ -162,9 +162,22 @@ pub(super) fn audio_thread(
);
}
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
// The frame currently being encoded. Reused rather than collected fresh each time: it is
// filled from `acc` on the normal path and padded out with silence on the infill path, and
// one buffer covers both without allocating 200 times a second.
let mut frame_buf: Vec<f32> = Vec::with_capacity(frame_len);
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
let mut opus_buf = vec![0u8; 4096];
let mut seq: u32 = 0;
// W-B1 — whether the wire covers a capture hole with silence, and for how long. See
// [`InfillPolicy`]: before this, a hole meant the loop simply blocked in `next_chunk` and
// NOTHING left the host for its duration, so the client's ring drained → underran →
// de-primed → re-primed, and a 30 ms hole became a much longer audible artifact.
let mut infill = crate::audio::capture_policy::InfillPolicy::default();
let mut last_chunk_at = std::time::Instant::now();
// Nothing may be synthesized before the first real frame: there is no continuity to protect
// yet, and the wire clock has no anchor to continue from.
let mut sent_any = false;
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
// (device unplug, daemon restart) — or a first open lost to session-start churn above —
// reopens instead of muting the rest of a multi-hour session. A quiet sink is NOT a death —
@@ -199,7 +212,11 @@ pub(super) fn audio_thread(
// Uninitialised on purpose: every read is preceded by the re-anchor at the top of the chunk
// loop, and seeding it with a placeholder would just be a value the compiler correctly points
// out is never read.
let mut next_pts_ns: u64;
//
// Seeded rather than left uninitialised now that infilled frames advance it too: it is the
// pts of the NEXT frame to leave, real or synthesized, and every send advances it by one
// frame. `sent_any` is what keeps the seed from ever reaching the wire.
let mut next_pts_ns: u64 = 0;
let mut pace_due: Option<std::time::Instant> = None;
if capturer.is_some() {
tracing::info!(
@@ -235,7 +252,30 @@ pub(super) fn audio_thread(
}
}
}
let chunk = match capturer.as_mut().unwrap().next_chunk() {
// Wake on whichever comes first: a capture chunk, or the moment the wire next has
// something to say. Waiting only on capture is what made a hole cost more than the audio
// it swallowed — see [`InfillPolicy`].
let waited = if infill.exhausted() || !sent_any {
// Nothing is owed until real audio returns: either the infill budget is spent (the
// host is not glitching, it is QUIET) or nothing has ever been sent, so there is no
// continuity to hold. Block the way this loop always did rather than waking two
// hundred times a second to decide to stay silent — a session that starts on a quiet
// desktop would otherwise spin until the first sound.
capturer.as_mut().unwrap().next_chunk()
} else {
let now = std::time::Instant::now();
// A frame that is due but has no audio behind it cannot be acted on until the hole is
// old enough to be worth covering, so wait for the LATER of the two — waiting only for
// the due time would spin through the window between them.
let ready_at = match pace_due {
Some(due) if acc.len() >= frame_len => due,
Some(due) => due.max(last_chunk_at + crate::audio::capture_policy::INFILL_AFTER),
None => now + FRAME_INTERVAL,
};
let budget = ready_at.saturating_duration_since(now).min(PACE_MAX_SLEEP);
capturer.as_mut().unwrap().next_chunk_within(budget)
};
let chunk = match waited {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "audio capture lost — reopening");
@@ -244,41 +284,68 @@ pub(super) fn audio_thread(
continue;
}
};
// Anchor the sample clock on THIS chunk's arrival. PipeWire hands us a buffer of already
// captured audio, so the newest sample in `acc` is ~now and the oldest is one whole
// buffer-occupancy earlier. Re-deriving the anchor every chunk (rather than free-running
// a counter) keeps the stamp tied to the capture device's own cadence, so a drifting or
// resampling graph corrects itself instead of accumulating error over a long session.
let arrival_ns = now_ns();
acc.extend_from_slice(&chunk);
let queued_frames = (acc.len() / want as usize) as u64;
next_pts_ns = arrival_ns.saturating_sub(queued_frames * 1_000_000_000 / SAMPLE_RATE as u64);
while acc.len() >= frame_len {
// Hold each frame until its slot on the audio clock. The FIRST frame of a chunk is
// already due (its samples are the oldest we hold), so this only ever delays the
// tail of a multi-frame chunk — exactly the burst we are trying not to send. A
// schedule that has fallen more than one frame behind is re-anchored rather than
// chased, so a scheduling hiccup cannot turn into a permanent send-time debt.
if !chunk.is_empty() {
if infill.chunk_arrived() {
// The wire fell silent across that hole. The partial frame in `acc` and the
// redundancy predecessor both describe audio from before a discontinuity, so
// splicing either onto what follows is a click plus a pts that lies about it.
acc.clear();
prev_frame.clear();
}
last_chunk_at = std::time::Instant::now();
// Anchor the sample clock on THIS chunk's arrival. PipeWire hands us a buffer of
// already captured audio, so the newest sample in `acc` is ~now and the oldest is one
// whole buffer-occupancy earlier. Re-deriving the anchor every chunk (rather than
// free-running a counter) keeps the stamp tied to the capture device's own cadence, so
// a drifting or resampling graph corrects itself instead of accumulating error over a
// long session.
let arrival_ns = now_ns();
acc.extend_from_slice(&chunk);
let queued_frames = (acc.len() / want as usize) as u64;
let anchor =
arrival_ns.saturating_sub(queued_frames * 1_000_000_000 / SAMPLE_RATE as u64);
// Never step backwards. Infilled frames advanced the wire clock while capture was
// away, and an anchor re-derived from this chunk's arrival can land at or before the
// last frame we already sent.
next_pts_ns = anchor.max(next_pts_ns);
}
// Everything the wire owes for the slots that have come due — real or synthesized, one
// schedule, one encoder, one `seq`. A schedule that has fallen more than one frame behind
// is re-anchored rather than chased, so a scheduling hiccup cannot turn into a permanent
// send-time debt.
loop {
let now = std::time::Instant::now();
match pace_due {
Some(due) if due > now => {
let wait = due - now;
// Never sleep longer than the audio we are holding: `next_chunk` has to be
// serviced or the capture channel backs up and starts dropping.
std::thread::sleep(wait.min(PACE_MAX_SLEEP));
}
Some(due) if due > now => break, // this frame's slot has not arrived yet
Some(due) if now.duration_since(due) > PACE_REANCHOR => pace_due = None,
_ => {}
}
frame_buf.clear();
if acc.len() >= frame_len {
frame_buf.extend(acc.drain(..frame_len));
} else if !sent_any {
break;
} else {
match infill.decide(last_chunk_at.elapsed()) {
crate::audio::capture_policy::Infill::Silence => {
// Pad the partial frame out with silence and send THAT, rather than
// leaving it for post-gap samples to complete: one frame carrying audio
// from both sides of a hole is a click, and its pts is a lie about when
// half of it was captured.
frame_buf.append(&mut acc);
frame_buf.resize(frame_len, 0.0);
}
crate::audio::capture_policy::Infill::Wait
| crate::audio::capture_policy::Infill::Quiet => break,
}
}
pace_due = Some(pace_due.unwrap_or_else(std::time::Instant::now) + FRAME_INTERVAL);
let mut frame: Vec<f32> = acc.drain(..frame_len).collect();
if gain != 1.0 {
punktfunk_core::audio::apply_gain(&mut frame, gain);
punktfunk_core::audio::apply_gain(&mut frame_buf, gain);
}
let pts_ns = next_pts_ns;
next_pts_ns += FRAME_MS as u64 * 1_000_000;
match enc.encode_float(&frame, &mut opus_buf) {
match enc.encode_float(&frame_buf, &mut opus_buf) {
Ok(n) => {
let opus = &opus_buf[..n];
let d = if redundancy {
@@ -299,6 +366,9 @@ pub(super) fn audio_thread(
prev_frame.extend_from_slice(opus);
}
seq = seq.wrapping_add(1);
// From here there is a continuity worth protecting, and `next_pts_ns` has a
// real anchor to continue from — both preconditions for synthesizing anything.
sent_any = true;
}
Err(e) => {
opus_encode_errs += 1;
+253 -6
View File
@@ -271,10 +271,21 @@ impl PhaseCtl {
/// budget catches any residual chase the statistics miss. New in v3: ANTIPODE DAMPING — an
/// error within 1 ms of ±period/2 flips sign on sampling noise (measured as 0↔2↔4 ms offset
/// chatter), so near-antipode steps are halved until the error commits to a side.
///
/// New in v4 (design/host-source-stutter-fixes.md WP-A1, from the 2026-08-15 Skynet log): a host
/// whose coherence oscillates around [`COHERENCE_FLOOR_MILLI`](Self::COHERENCE_FLOOR_MILLI) used
/// to flap forever, because engagement needed ONE coherent report and the incoherent disengage
/// asked for NO backoff — 41 engage/disengage cycles in 24 minutes on a KWin host. Each cycle is
/// a self-inflicted timing step in both directions (engage starts holding submits by up to a
/// period; disengage drops the offset to 0 and the next frames leave that much earlier), so the
/// controller was manufacturing the very arrival jitter it exists to remove. Hence: engagement
/// needs SUSTAINED coherence, each incoherent cycle backs off longer than the last, and a host
/// that cannot hold coherence at all parks for the session.
struct PhaseController {
/// Grid offset, ns ∈ [0, period). Meaningful only while engaged.
offset_ns: i64,
/// The grid's epoch; `None` = disengaged (no submit-grid sleeps, zero cost).
/// The grid's epoch; `None` = disengaged (no submit-grid sleeps, zero cost). Doubles as the
/// lock's age — it is stamped at engage and cleared at disengage, nowhere else.
epoch: Option<std::time::Instant>,
/// Last adjust instant (~1 Hz cadence).
last_adjust: std::time::Instant,
@@ -282,6 +293,13 @@ struct PhaseController {
cum_travel_ns: i64,
/// Consecutive incoherent reports; 3 disengage.
incoherent_streak: u32,
/// Consecutive COHERENT reports — the v4 engage gate.
coherent_streak: u32,
/// Incoherent disengages this session: escalates the backoff, and blows the fuse. Forgiven
/// by a lock that holds for [`LOCK_STABLE`](Self::LOCK_STABLE).
incoherent_cycles: u32,
/// Fuse blown — parked for the session, no further engagement and no further log lines.
fused: bool,
/// Adjust ticks to sit out after a disengage before re-engaging.
reengage_backoff: u32,
}
@@ -304,6 +322,19 @@ impl PhaseController {
const ANTIPODE_GUARD_NS: i64 = 1_000_000;
/// Adjust ticks sat out after a disengage (travel exhaustion) before trying again.
const REENGAGE_BACKOFF: u32 = 10;
/// Consecutive coherent reports the grid must see before it engages (v4). One was enough in
/// v3, which is why a host hovering at the coherence floor re-engaged within a second of
/// every disengage; at the ~1 Hz report cadence this asks for ~5 s of a phase worth locking.
const ENGAGE_COHERENT_REPORTS: u32 = 5;
/// Incoherent cycles before the grid parks for the session. A host that has failed this many
/// times is telling us its arrival phase is not lockable, and permanently disengaged (today's
/// default, zero added latency) is strictly better than another cycle of steps.
const INCOHERENT_FUSE: u32 = 8;
/// Escalation ceiling — `REENGAGE_BACKOFF << 5` = 320 ticks ≈ 5 min.
const MAX_BACKOFF_SHIFT: u32 = 5;
/// A lock held this long forgives the session's escalation: a transient bad patch (a shader
/// storm, a game launch) must not fuse a host that is otherwise perfectly lockable.
const LOCK_STABLE: std::time::Duration = std::time::Duration::from_secs(60);
fn new() -> PhaseController {
PhaseController {
@@ -312,6 +343,9 @@ impl PhaseController {
last_adjust: std::time::Instant::now(),
cum_travel_ns: 0,
incoherent_streak: 0,
coherent_streak: 0,
incoherent_cycles: 0,
fused: false,
reengage_backoff: 0,
}
}
@@ -320,10 +354,14 @@ impl PhaseController {
self.epoch.is_some()
}
fn disengage(&mut self, reason: &'static str, backoff: u32) {
/// `coherence_milli` is the report that caused the disengage: without it the log showed how
/// far the offset had walked but not how far below the gate the phase actually was, which is
/// the number that says whether the host is marginal or hopeless.
fn disengage(&mut self, reason: &'static str, backoff: u32, coherence_milli: u16) {
if self.engaged() {
tracing::info!(
offset_ms = self.offset_ns as f64 / 1e6,
coherence_milli,
reason,
"phase lock: disengaging the submit grid"
);
@@ -331,6 +369,10 @@ impl PhaseController {
self.epoch = None;
self.offset_ns = 0;
self.cum_travel_ns = 0;
// Both streaks restart: re-engaging costs a fresh ENGAGE_COHERENT_REPORTS of proof, and
// the next disengage costs a fresh three incoherent reports.
self.incoherent_streak = 0;
self.coherent_streak = 0;
self.reengage_backoff = backoff;
}
@@ -338,7 +380,7 @@ impl PhaseController {
/// Sign convention: a positive (shortest-way) error means frames arrive too early and wait
/// at the client — submit LATER (grow the offset); negative — earlier.
fn adjust(&mut self, r: &punktfunk_core::quic::PhaseReport, period_ns: i64) {
if period_ns <= 0 {
if period_ns <= 0 || self.fused {
return;
}
self.last_adjust = std::time::Instant::now();
@@ -349,13 +391,43 @@ impl PhaseController {
let coherent =
r.coherence_milli == u16::MAX || r.coherence_milli >= Self::COHERENCE_FLOOR_MILLI;
if !coherent {
self.coherent_streak = 0;
self.incoherent_streak += 1;
if self.incoherent_streak >= 3 {
self.disengage("incoherent arrival phase", 0);
// Only a disengage that tore down an ENGAGED grid cost the stream a timing step.
// Counting the others would let a launch-time shader storm — minutes of genuinely
// incoherent arrival before the controller ever locks — blow the fuse on a host
// that then locks perfectly for the next three hours.
if self.engaged() {
self.incoherent_cycles += 1;
if self.incoherent_cycles >= Self::INCOHERENT_FUSE {
self.fused = true;
tracing::info!(
cycles = self.incoherent_cycles,
coherence_milli = r.coherence_milli,
"phase lock: arrival phase incoherent on this host — parked for the \
session"
);
}
}
// Each cycle waits longer than the last: 10 ticks (~10 s) doubling to 320 (~5 min).
let backoff = Self::REENGAGE_BACKOFF
<< self
.incoherent_cycles
.saturating_sub(1)
.min(Self::MAX_BACKOFF_SHIFT);
self.disengage("incoherent arrival phase", backoff, r.coherence_milli);
}
return;
}
self.incoherent_streak = 0;
self.coherent_streak = self.coherent_streak.saturating_add(1);
// A lock this old has proven itself — forgive the escalation so a session that hits one
// bad patch an hour is not slowly fused by it. Checked here rather than at disengage so
// the state decays while the lock is good, not only when it is lost.
if self.epoch.is_some_and(|e| e.elapsed() >= Self::LOCK_STABLE) {
self.incoherent_cycles = 0;
}
let target = Self::TARGET_LEAD_FLOOR_NS.max(r.uncertainty_ns as i64 + 1_000_000);
// Signed SHORTEST-WAY error around the period.
let raw = (r.arrival_lead_ns as i64 - target).rem_euclid(period_ns);
@@ -369,8 +441,16 @@ impl PhaseController {
return;
}
if !self.engaged() {
// Hysteresis: an offset worth holding submits for has to be backed by a phase that
// stayed coherent, not by the one report that happened to clear the gate.
if self.coherent_streak < Self::ENGAGE_COHERENT_REPORTS {
return;
}
self.epoch = Some(std::time::Instant::now());
tracing::info!("phase lock: engaging the submit grid");
tracing::info!(
coherence_milli = r.coherence_milli,
"phase lock: engaging the submit grid"
);
}
let mut step = error.clamp(-Self::MAX_STEP_NS, Self::MAX_STEP_NS);
// Antipode damping: this error sits where its sign is a coin flip — half steps until
@@ -382,7 +462,7 @@ impl PhaseController {
self.cum_travel_ns += step.abs();
if self.cum_travel_ns > period_ns + period_ns / 4 {
tracing::info!("phase lock: travel budget exhausted without convergence — disengaging");
self.disengage("travel budget", Self::REENGAGE_BACKOFF);
self.disengage("travel budget", Self::REENGAGE_BACKOFF, r.coherence_milli);
}
}
@@ -5233,6 +5313,173 @@ mod tests {
);
}
// ---- v4 flap hygiene (design/host-source-stutter-fixes.md WP-A1) ----
//
// These exercise the controller's reaction to the COHERENCE STATISTIC rather than the
// statistic itself (`report_from_lead` above covers that), because the shape being replayed
// is "coherence oscillating around the floor" — far easier to state directly than to conjure
// out of a sample spread, and the number under test is the gate, not the estimator.
/// Just above / just below the gate — the neighbourhood the field host lived in.
const COHERENT: u16 = PhaseController::COHERENCE_FLOOR_MILLI + 40;
const INCOHERENT: u16 = PhaseController::COHERENCE_FLOOR_MILLI - 40;
/// A lead far enough from the target to be worth acting on (error ≫ deadband).
const ACTIONABLE_LEAD: i64 = 7_500_000;
fn report_at(coherence_milli: u16, lead_ns: i64) -> punktfunk_core::quic::PhaseReport {
punktfunk_core::quic::PhaseReport {
next_latch_host_ns: 0,
latch_period_ns: SIM_P as u32,
uncertainty_ns: 1_000_000,
arrival_lead_ns: lead_ns.rem_euclid(SIM_P) as u32,
coherence_milli,
}
}
/// One full cycle on a host that keeps losing its phase: engage on a sustained good phase,
/// then lose it. Returns the backoff the disengage asked for, and leaves the controller with
/// that backoff spent so the caller can run another cycle.
fn one_incoherent_cycle(c: &mut PhaseController) -> u32 {
for _ in 0..PhaseController::ENGAGE_COHERENT_REPORTS {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
}
assert!(c.engaged(), "the cycle must engage before it tears down");
for _ in 0..3 {
c.adjust(&report_at(INCOHERENT, ACTIONABLE_LEAD), SIM_P);
}
let asked = c.reengage_backoff;
while !c.fused && c.reengage_backoff > 0 {
c.adjust(&report_at(INCOHERENT, ACTIONABLE_LEAD), SIM_P);
}
asked
}
#[test]
fn engage_requires_sustained_coherence() {
let mut c = PhaseController::new();
for i in 1..PhaseController::ENGAGE_COHERENT_REPORTS {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
assert!(!c.engaged(), "engaged on only {i} coherent report(s)");
}
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
assert!(
c.engaged(),
"sustained coherence must still engage the grid"
);
// …and the proof is CONSECUTIVE: one bad report puts it back to zero, which is the whole
// difference from v3 on a host that clears the gate every other second.
let mut c = PhaseController::new();
for _ in 0..PhaseController::ENGAGE_COHERENT_REPORTS - 1 {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
}
c.adjust(&report_at(INCOHERENT, ACTIONABLE_LEAD), SIM_P);
for _ in 0..PhaseController::ENGAGE_COHERENT_REPORTS - 1 {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
}
assert!(
!c.engaged(),
"a broken streak must not count toward engaging"
);
}
#[test]
fn incoherent_disengage_backoff_escalates() {
let mut c = PhaseController::new();
let asked: Vec<u32> = (0..4).map(|_| one_incoherent_cycle(&mut c)).collect();
assert_eq!(
asked,
vec![10, 20, 40, 80],
"each cycle must wait longer than the last (v3 asked for zero, every time)"
);
}
#[test]
fn fuse_after_repeated_cycles() {
let mut c = PhaseController::new();
for _ in 0..PhaseController::INCOHERENT_FUSE {
one_incoherent_cycle(&mut c);
}
assert!(
c.fused,
"a host that never holds a lock must park for the session"
);
// Parked means parked: even a v1-style bypass report (u16::MAX) cannot wake it.
for _ in 0..50 {
c.adjust(&report_at(u16::MAX, ACTIONABLE_LEAD), SIM_P);
}
assert!(!c.engaged(), "a fused controller must stay disengaged");
}
#[test]
fn stable_lock_resets_escalation() {
let mut c = PhaseController::new();
one_incoherent_cycle(&mut c);
one_incoherent_cycle(&mut c);
assert_eq!(c.incoherent_cycles, 2, "two cycles should have escalated");
for _ in 0..PhaseController::ENGAGE_COHERENT_REPORTS {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
}
assert!(c.engaged());
// Buy the minute by moving the epoch back, the same way the grid test places its epoch —
// the lock's age IS the epoch's age.
c.epoch = Some(
std::time::Instant::now()
- PhaseController::LOCK_STABLE
- std::time::Duration::from_secs(1),
);
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
assert_eq!(
c.incoherent_cycles, 0,
"a lock that held past LOCK_STABLE must forgive the escalation"
);
}
/// The 2026-08-15 Skynet log in one function: 24 minutes of a host whose arrival phase sits
/// ON the coherence gate, flipping sides every few reports. v3 engaged on the FIRST coherent
/// report after a zero-backoff disengage and logged **41 engage/disengage cycles** — 82
/// self-inflicted timing steps (each engage holds submits by up to a period, each disengage
/// drops the offset to zero) on a session whose transport was provably clean.
#[test]
fn flap_replay_stops_the_engage_churn() {
let mut c = PhaseController::new();
let mut rng = Lcg(23);
let (mut engagements, mut reports, mut coherent_side) = (0u32, 0u32, true);
while reports < 24 * 60 {
// Runs of 2-4 reports a side: "every few reports", the logged shape.
let run = 2 + rng.next_noise(3).rem_euclid(3) as u32;
for _ in 0..run {
let was = c.engaged();
let side = if coherent_side { COHERENT } else { INCOHERENT };
c.adjust(&report_at(side, ACTIONABLE_LEAD), SIM_P);
engagements += u32::from(!was && c.engaged());
reports += 1;
}
coherent_side = !coherent_side;
}
assert!(
engagements <= 2,
"24 min of gate-hovering must not churn the grid: {engagements} engagements"
);
// …and the cure must not be "never engage again": a host that settles still locks. This
// is R1 in the plan's register, asserted rather than argued.
assert!(
!c.fused,
"flapping that never engaged must not blow the fuse"
);
// The replay ends mid-backoff — a bad patch always does — so recovery costs that backoff
// plus the engage proof, ~15 s at the report cadence. That delay IS the trade: every
// second of it is spent in today's disengaged default, which adds no latency at all.
for _ in 0..PhaseController::REENGAGE_BACKOFF + PhaseController::ENGAGE_COHERENT_REPORTS {
c.adjust(&report_at(COHERENT, ACTIONABLE_LEAD), SIM_P);
}
assert!(
c.engaged(),
"a phase that finally holds must still get the grid"
);
}
/// The 2026-08-14 Hyprland field report, in one function: xdph advertises `Hidden|Embedded`,
/// so a session that asked for cursor metadata is served **Embedded** — no `SPA_META_Cursor`
/// is ever sent, whatever the pointer does. The host must then (a) stop planning a metadata
+52 -2
View File
@@ -137,7 +137,19 @@
// way). Additive and client-local: the mask, the expiry and the `AccessUpdate` message all
// shipped with the Welcome's trailing-field append (old peers skip them in both directions),
// so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 22
// v23: added `punktfunk_connection_audio_plc` — one frame of libopus packet-loss concealment,
// synthesized from the connection's OWN decoder state, for an embedder whose playout ring is
// draining because nothing is arriving (design/host-source-stutter-fixes.md WP-C1). The three
// Rust clients conceal a packet drought on their decode thread; Apple's ring is Swift and its
// decoder sits behind this ABI, so without a call it had no way to reach the one thing that can
// extrapolate the missing audio — a second decoder would conceal from empty state, because PLC
// extrapolates from the last decoded frame. A NEW symbol: every existing function keeps its
// signature and behaviour, and an embedder that never calls it behaves exactly as before (it
// simply de-primes over droughts, as all four clients used to). Frames it returns carry `seq`
// and `pts_ns` of `0` — concealed audio was never on the wire and must not reach an A/V-sync
// observation. Additive and client-local: nothing new is sent or parsed, so [`WIRE_VERSION`] is
// unchanged.
#define PUNKTFUNK_ABI_VERSION 23
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -2862,7 +2874,9 @@ PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t
// IN FRONT of the arriving frame in the same buffer — `out->frame_count` then covers the
// concealed frames plus the real one (`out->seq`/`out->pts_ns` are the real packet's). The
// embedder just writes the whole buffer to its ring, same as any other frame; gaps arrive
// pre-healed, exactly as they do on the clients that decode outside core.
// pre-healed, exactly as they do on the clients that decode outside core. That covers a gap a
// LATER packet reveals; when the wire goes quiet instead, see
// [`punktfunk_connection_audio_plc`].
//
// # Safety
// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
@@ -2871,6 +2885,42 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Synthesize ONE frame of concealment from the in-core decoder's own state — no packet involved,
// nothing pulled off the wire (design/host-source-stutter-fixes.md, WP-C1).
//
// [`punktfunk_connection_next_audio_pcm`] heals a gap the SEQUENCE reveals, which needs a later
// packet to arrive and reveal it. When the wire simply goes quiet — a delivery stall on a
// bunching Wi-Fi link, or a host whose capture stalled — nothing arrives to reveal anything: the
// embedder's playout ring drains to empty, its callback runs short, and its de-jitter policy
// de-primes and then re-primes a whole target's worth of fresh silence. The artifact is far
// longer than the audio actually missing.
//
// So on a `NO_FRAME` timeout with a DRAINING ring, ask for this instead. The policy stays on the
// embedder's side because that is where its two ingredients live — the ring depth and the clock
// since the last packet — and it must be: bounded in TIME (roughly twice the ring's own de-prime
// fuse), never in callbacks or frames, and gated on the ring genuinely running out. A drought a
// deep ring covers is inaudible, and concealing it would insert audio the late packets are about
// to duplicate, pushing the stream permanently later. Core supplies only the mechanism, one frame
// per call, at the cadence the embedder drains at.
//
// Returns [`PunktfunkStatus::NoFrame`] when nothing has decoded yet — PLC extrapolates from the
// last decoded frame, so before there is one there is no state to extrapolate from — and if
// libopus declines to interpolate. Both mean "write nothing this tick", exactly like a timeout.
//
// `out->seq` and `out->pts_ns` read 0: this frame was never on the wire, so it has no sequence
// number and no capture instant, and it must never be fed to an A/V-sync observation.
// `out->samples` borrows connection memory until the next PCM call on this handle — the SAME
// slot [`punktfunk_connection_next_audio_pcm`] hands out, so call both from the one audio thread.
//
// Frames taken this way are subtracted from the concealment the next arriving packet asks for, so
// a packet genuinely lost inside a covered drought is not concealed twice.
//
// # Safety
// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
PunktfunkStatus punktfunk_connection_audio_plc(PunktfunkConnection *c, PunktfunkAudioPcm *out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio