fix(apple): stage-4 deadline presenter (CAMetalDisplayLink) — iOS default; gate depth back to 1

Field verdict on the depth-2 gate (M4 iPad Pro, 2752x2064@120): display stage
22-28 ms vs depth-1's 14 — a REGRESSION, not the predicted 5-8. Post-mortem:
any bounded-FIFO pacing keeps a STANDING queue on the always-vsync-latch
platforms. One burst fills every admitted slot and, with arrivals and latches
then running at the same rate, occupancy never returns to zero — each gate
slot costs one full refresh, permanently (the ladder fits exactly: arrival ~3
slots -> 30+ ms, depth 2 -> 22-28, depth 1 -> 14). A bounded FIFO caps the
queue; nothing ever drains it. And depth 1 serializes presents on the on-glass
callback's delivery lag, so neither rung can approach the sub-refresh floor.

Stage-4 (PresentPacing.deadline) inverts drawable ownership instead:
- A CAMetalDisplayLink on its own runloop thread vends ONE deadline-timed
  drawable per refresh (preferredFrameLatency 1) into a newest-wins LatestBox;
  an unpresented vend is replaced by the next (back to the pool).
- The render thread pairs it with the newest decoded frame the moment either
  half arrives — the common case presents a frame INSTANTLY into an already-
  vended drawable, latching the upcoming refresh. No image queue can form and
  nothing waits on on-glass callbacks (they only feed the meters now).
- A stashed drawable can lag a mid-session HDR reconfigure by one vend:
  encodePresent skips the mismatched-format vend (frame re-rings) instead of
  tripping Metal validation.
- iOS/iPadOS defaults to stage-4; tvOS keeps stage-3 until its own A/B
  (PUNKTFUNK_PRESENTER=stage4); macOS resolves "stage4" back to its default
  (the sync-off/DCP-panic saga — deadline pacing lands there deliberately or
  not at all). Settings picker gains Stage 4 with the derived default marker.
- Gate depth defaults to 1 everywhere again; PUNKTFUNK_GATE_DEPTH stays only
  to reproduce the standing-queue ladder on-device.
- PUNKTFUNK_PRESENT_DEBUG gains latchMs p50/max (present-issue -> on-glass:
  standing queue reads ~n x period, healthy reads < 1 period) + noDrawable=.

swift test (14/14) + full Punktfunk-iOS device build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:35:43 +02:00
parent d2daeacc60
commit ab2bcc8e68
7 changed files with 480 additions and 117 deletions
@@ -50,11 +50,12 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel"
/// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// Which presenter runs a session: "stage2" (explicit decode + Metal present on frame
/// arrival the macOS default), "stage3" (same pipeline, glass-gated present pacing the
/// tvOS default), "stage4" (CAMetalDisplayLink deadline pacing, iOS/tvOS only the iOS
/// default; see Stage2Pipeline's PresentPacing for the ladder), or "stage1" (DEBUG-only
/// system-layer fallback). Resolved once per session by SessionPresenter;
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 overrides it for A/B.
public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes