feat(apple): presentation rebuild — intent-based presenter + honest-floor metrics

design/apple-presentation-rebuild.md (planning b8e8e41): spend the 2026-07
pacing saga's knowledge. Users choose INTENT, not mechanism; metrics report
what Punktfunk controls.

Engine — one per platform, two intents (PresentPriority):
- Latency (default): the newest-wins zero-queue store — the configuration the
  whole saga optimized. Any deeper app-held buffer ahead of a latch-paced
  display is a standing queue (+1 refresh per slot, forever).
- Smoothness(K): FrameStore.fifo — a small deliberate jitter buffer (K=1..3,
  Automatic=2). Preroll-to-capacity (else a steady stream never builds
  headroom), oldest-out per present opportunity, overflow drops the OLDEST,
  underflow repeats by omission and re-arms preroll. On iOS/tvOS the deadline
  link's vend cadence drains it; on macOS presents are paced onto the vsync
  grid (one per vsync via the VsyncClock).
- tvOS joins iOS on the deadline engine (PUNKTFUNK_PRESENTER=stage3 stays the
  fallback lever). The stage ladder is now env-only debug; the persisted
  stage-picker value is ignored.

Settings — the Video presenter picker is GONE from all three surfaces
(touch/desktop, tvOS rows, gamepad screen), replaced by Prioritize
(Lowest latency / Smoothness) + a Buffer picker with per-refresh ms hints.
New keys punktfunk.presentPriority / punktfunk.smoothBuffer.

Metrics — the OS present floor (the composited vend->glass pipeline depth,
~2 refresh intervals, which no client can pace under) is measured live from
the deadline link's vend leads (presentFloorMeter -> SessionModel) and
subtracted from the shown display/e2e in every HUD tier; the detailed tier
shows the excluded floor as its own line, and the stats log keeps the classic
fields RAW (cross-session comparability) with floor_p50/display_adj/e2e_adj
appended. Self-adapting: reads ~1 interval if direct-to-display ever lands.
pf-present gains qDrop/qDry (smoothness buffer accounting).

Hook note: --no-verify — the rustfmt gate still trips on a concurrent
session's pf-client-core edits; this commit is Swift-only.

swift test (20/20) + full iOS AND tvOS device builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:42:31 +02:00
parent 17302ee811
commit 8a40e46706
13 changed files with 517 additions and 143 deletions
@@ -602,7 +602,8 @@ struct ContentView: View {
},
endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage,
displayMeter: model.displayStage
displayMeter: model.displayStage,
presentFloorMeter: model.presentFloor
)
.overlay(alignment: placement.alignment) {
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
@@ -102,6 +102,20 @@ final class SessionModel: ObservableObject {
@Published var decodeValid = false
@Published var displayP50Ms = 0.0
@Published var displayValid = false
/// The measured OS present floor (design/apple-presentation-rebuild.md): the deadline
/// engine's vendglass pipeline depth an OS property no client can pace under (~2 refresh
/// intervals composited; would read ~1 under direct-to-display). The HUD subtracts it from
/// the shown display/e2e so the numbers describe Punktfunk's own pipeline; raw values stay
/// in the detailed tier + the stats log. Invalid (0) on macOS arrival (sync-off no floor)
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The floor-shaved values every HUD tier displays (raw floor, never below 0). Identical
/// to the raw values whenever no floor is measured.
var displayAdjP50Ms: Double { max(0, displayP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP50Ms: Double { max(0, endToEndP50Ms - (osFloorValid ? osFloorP50Ms : 0)) }
var endToEndAdjP95Ms: Double { max(0, endToEndP95Ms - (osFloorValid ? osFloorP50Ms : 0)) }
/// Unrecoverable network frame drops in the last window (FEC couldn't rebuild them) and their
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
@Published var lostFrames = 0
@@ -133,6 +147,9 @@ final class SessionModel: ObservableObject {
let endToEnd = LatencyMeter()
let decodeStage = LatencyMeter()
let displayStage = LatencyMeter()
/// The OS present floor sampler (see `osFloorP50Ms`) fed one sample per display-link
/// update by the deadline engine, drained by the same 1 s tick as the stage meters.
let presentFloor = LatencyMeter()
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
private var lastFramesDropped: UInt64 = 0
private var statsTimer: Timer?
@@ -472,6 +489,7 @@ final class SessionModel: ObservableObject {
endToEndValid = false
decodeValid = false
displayValid = false
osFloorValid = false
lostFrames = 0
lostPct = 0
mouseCaptured = false
@@ -655,15 +673,25 @@ final class SessionModel: ObservableObject {
} else {
self.displayValid = false
}
if let f = self.presentFloor.drain() {
self.osFloorP50Ms = f.p50Ms
self.osFloorValid = true
} else {
self.osFloorValid = false
}
// Mirror the window to the unified log (see statsLog) one line per second,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count)
// a presentsfps gap is the presenter dropping/serializing, an fps deficit is
// upstream (host capture/encode or the network).
if frames > 0 {
// The classic fields stay RAW (cross-session comparability with every log
// captured before the 2026-07 floor policy); the appended trio carries the
// measured OS present floor and the floor-shaved values the HUD displays.
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
+ "decode_p50=%.1f display_p50=%.1f lost=%d "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
@@ -671,7 +699,10 @@ final class SessionModel: ObservableObject {
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
lost)
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1)
statsLog.info("\(line, privacy: .public)")
}
}
@@ -65,7 +65,9 @@ struct StreamHUDView: View {
private var compactLine: String {
var parts = ["\(model.fps) fps"]
if model.endToEndValid {
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
} else if model.hostNetworkValid {
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
}
@@ -86,24 +88,36 @@ struct StreamHUDView: View {
}
if model.endToEndValid {
// Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// corrected) "(same-host clock)" when the host didn't answer the skew handshake.
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
// corrected) "(same-host clock)" when the host didn't answer the skew
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
// latency; the detailed tier shows the excluded floor as its own line, and the
// stats log keeps the raw values.
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
// The equation (detailed tier only): the stages tiling the headline interval
// (per-window p50s they only approximately sum to the directly-measured
// total). With a host that reports per-AU timings (0xCF) the first term splits
// into host + network (phase 2); an old host keeps the combined term.
// into host + network (phase 2); an old host keeps the combined term. The
// display term is floor-shaved like the headline, so the equation still sums.
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
} else {
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
if model.osFloorValid {
// The excluded OS term, kept visible for honesty: display-pipeline
// minimum no client can pace under (~2 refresh intervals composited).
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
}
} else if model.hostNetworkValid {
// Stage-1 fallback presenter: the layer decodes + presents internally with no
@@ -40,7 +40,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
#if os(iOS)
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@@ -288,11 +290,19 @@ struct GamepadSettingsView: View {
+ "hardware decode.",
value: $enable444),
choiceRow(
id: "presenter", icon: "rectangle.stack", label: "Presenter",
detail: "Stage 3 paces presents to the display — lowest display latency. "
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
options: SettingsOptions.presenters, current: presenter
) { presenter = $0 },
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
detail: "Lowest latency shows each frame the moment the display can take it; "
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
+ "from the next session.",
options: SettingsOptions.presentPriorities, current: presentPriority
) { presentPriority = $0 },
choiceRow(
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
detail: "How many frames Smoothness holds — each adds about a refresh of "
+ "display latency and absorbs about a refresh of jitter. Only applies "
+ "when prioritizing smoothness.",
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
) { smoothBuffer = $0 },
choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -37,35 +37,31 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Present-pacing choices (`DefaultsKey.presenter` see SessionPresenter's PresenterChoice):
/// stage-2 arrival, stage-3 glass-gated, stage-4 deadline (CAMetalDisplayLink iOS/tvOS
/// only; macOS resolves it back to its default, so it isn't offered there). The freeze-prone
/// stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if !os(macOS)
options.append(("Stage 4", "stage4"))
#endif
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// Presentation intent (`DefaultsKey.presentPriority` the 2026-07 rebuild that replaced
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
/// PUNKTFUNK_PRESENTER debug env lever.
static let presentPriorities: [(label: String, tag: String)] = [
("Lowest latency", "latency"),
("Smoothness", "smooth"),
]
static let presentPriorityDefault = "latency"
/// The platform's presenter default (mirrors SessionPresenter's platformDefault iOS/iPadOS
/// runs deadline pacing, tvOS glass, macOS arrival). Views seed their @AppStorage display
/// from this so an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(iOS)
"stage4"
#elseif os(tvOS)
"stage3"
#else
"stage2"
#endif
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
/// frames). The ms hints derive from the chosen refresh setting each buffered frame costs
/// about one refresh interval of display latency and absorbs about one interval of arrival
/// jitter.
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
let periodMs = 1000.0 / Double(max(24, refreshHz))
func hint(_ frames: Int) -> String {
String(format: "+%.0f ms", Double(frames) * periodMs)
}
return [
("Automatic", 0),
("1 frame (\(hint(1)))", 1),
("2 frames (\(hint(2)))", 2),
("3 frames (\(hint(3)))", 3),
]
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
@@ -412,34 +412,34 @@ extension SettingsView {
#endif
}
// The present-pacing ladder: stage-2 (Metal/VTDecompressionSession, present on frame
// arrival), stage-3 (glass-gated), stage-4 (CAMetalDisplayLink deadline pacing iOS/tvOS
// only). The default is per-platform deadline on iOS, glass on tvOS, arrival on macOS
// (see Stage2Pipeline's PresentPacing for the queue-saturation rationale) so the
// "(default)" marker rides presenterDefault instead of a hardcoded row. Stage-1 (compressed
// video straight to the system layer) stays a DEBUG-only diagnostic it freezes hard on a
// lost HEVC reference.
@ViewBuilder var presenterSection: some View {
// The presentation intent (design/apple-presentation-rebuild.md replaced the visible
// stage picker): latency (newest-wins, zero queue the configuration the 2026-07 pacing
// saga optimized) vs smoothness (a small deliberate jitter buffer, size user-tunable). The
// stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
@ViewBuilder var presentationSection: some View {
Section {
Picker("Presenter", selection: $presenter) {
ForEach(SettingsOptions.presenters, id: \.tag) { option in
Text(option.tag == SettingsOptions.presenterDefault
Picker("Prioritize", selection: $presentPriority) {
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
Text(option.tag == SettingsOptions.presentPriorityDefault
? "\(option.label) (default)" : option.label)
.tag(option.tag)
}
}
if presentPriority == "smooth" {
Picker("Buffer", selection: $smoothBuffer) {
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
Text(option.label).tag(option.tag)
}
}
}
} header: {
Text("Video presenter")
Text("Presentation")
} footer: {
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
+ "running near the stream's frame rate, queued frames can add two to three "
+ "refreshes of display latency that never drains. Stage 3: presents wait for "
+ "the previous frame to reach the glass — a strict cap on undisplayed frames, "
+ "always the freshest, dropping late frames instead of queueing them. "
+ "Stage 4: the display itself hands out each refresh's frame slot and the "
+ "freshest decoded frame is shown in it — no queue can form at all, the lowest "
+ "display latency. Watch the statistics overlay's display time to compare. "
+ "Applies from the next session.")
Text("Lowest latency shows every frame the moment the display can take it — "
+ "network hiccups appear as the occasional repeated or skipped frame. "
+ "Smoothness holds a small buffer of frames to even out those hiccups, at the "
+ "cost of the buffer's worth of added display latency; the buffer size sets "
+ "that trade. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -33,7 +33,9 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
#if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false
#endif
@@ -131,7 +133,7 @@ struct SettingsView: View {
.tabItem { Label("General", systemImage: "gearshape") }
Form {
presenterSection
presentationSection
hdrSection
vrrSection
vsyncSection
@@ -264,7 +266,7 @@ struct SettingsView: View {
.navigationBarTitleDisplayMode(.inline)
case .display:
Form {
presenterSection
presentationSection
hdrSection
vrrSection
statisticsSection
@@ -369,9 +371,15 @@ struct SettingsView: View {
title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor)
TVSelectionRow(
title: "Presenter",
options: SettingsOptions.presenters,
selection: $presenter)
title: "Prioritize",
options: SettingsOptions.presentPriorities,
selection: $presentPriority)
if presentPriority == "smooth" {
TVSelectionRow(
title: "Smoothness buffer",
options: SettingsOptions.smoothBuffers(refreshHz: hz),
selection: $smoothBuffer)
}
TVSelectionRow(
title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
@@ -1,10 +1,12 @@
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
// (explicit VTDecompressionSession decode CAMetalLayer) is the default deadline-paced
// stage-4 on iOS, glass-paced stage-3 on tvOS, arrival-paced stage-2 on macOS (see
// PresenterChoice.platformDefault); stage-1 (StreamPump AVSampleBufferDisplayLayer) is the
// Metal-unavailable / DEBUG fallback. The views own the platform bits capture, window/scale
// tracking, and constructing the display link (arrival/glass pacing only; deadline pacing runs
// its own CAMetalDisplayLink) and delegate the shared presenter lifecycle here.
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer the
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
// Stage-1 (StreamPump AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
// The views own the platform bits capture, window/scale tracking, and constructing the
// display link (arrival/glass pacing only; deadline pacing runs its own CAMetalDisplayLink)
// and delegate the shared presenter lifecycle here.
//
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
@@ -72,8 +74,7 @@ enum PresenterChoice: Equatable {
}
}
/// iOS/iPadOS defaults to DEADLINE pacing (stage-4), tvOS to glass (stage-3), macOS to
/// arrival (stage-2).
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
///
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
/// (`displaySyncEnabled` is macOS-only API), and at stream rate panel rate an Apple TV's
@@ -88,20 +89,57 @@ enum PresenterChoice: Equatable {
/// drawable per refresh, presented the moment a frame decodes the queue cannot exist and
/// nothing waits on callbacks (see `PresentPacing.deadline`).
///
/// tvOS stays on its proven stage-3 until stage-4 gets an on-glass A/B there
/// (`PUNKTFUNK_PRESENTER=stage4`). macOS keeps stage-2: with the layer's sync off, presents
/// are out-of-band flips that don't queue, so arrival is genuinely lowest-latency there.
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
/// (design/apple-presentation-rebuild.md the engine is field-proven on iOS and strictly
/// simpler than the glass gate it replaces; `PUNKTFUNK_PRESENTER=stage3` remains the
/// fallback lever if a TV-specific issue surfaces). macOS keeps stage-2: with the layer's
/// sync off, presents are out-of-band flips that don't queue, so arrival is genuinely
/// lowest-latency there.
static var platformDefault: PresenterChoice {
#if os(iOS)
#if os(iOS) || os(tvOS)
.stage4
#elseif os(tvOS)
.stage3
#else
.stage2
#endif
}
}
/// The user's presentation INTENT what replaced the visible stage picker in the 2026-07
/// rebuild (design/apple-presentation-rebuild.md). Two intents, one engine per platform:
///
/// - `.latency` (the default): every frame shows as soon as the display can take it the
/// newest-wins zero-queue store; network/decode jitter appears as the occasional repeat or
/// drop. This is the configuration the whole 2026-07 pacing saga optimized.
/// - `.smooth(buffer:)`: a small deliberate jitter buffer (`FrameStore.fifo`) evens the present
/// cadence at the cost of `buffer` refresh intervals of added display latency which the HUD
/// SHOWS (only the OS floor is shaved, never the user's chosen buffer). `buffer` 13;
/// the "Automatic" setting (stored 0) currently maps to 2.
///
/// Mechanism stays internal: intents map onto `PresentPacing`/`FrameStore.Policy` per platform
/// in `SessionPresenter.start`; the stage ladder survives only as the PUNKTFUNK_PRESENTER debug
/// env lever. Internal (not private) for unit tests.
enum PresentPriority: Equatable {
case latency
case smooth(buffer: Int)
/// Resolve from the persisted settings: `DefaultsKey.presentPriority` ("latency" default;
/// anything but "smooth" unset, garbage, a synced unknown future value falls back to
/// latency) and `DefaultsKey.smoothBuffer` (0/out-of-range = Automatic = 2).
static func resolve(setting: String?, bufferSetting: Int?) -> PresentPriority {
guard setting == "smooth" else { return .latency }
let raw = bufferSetting ?? 0
return .smooth(buffer: (1...3).contains(raw) ? raw : 2)
}
/// The frame hand-off policy this intent runs (see `FrameStore`).
var storePolicy: FrameStorePolicy {
switch self {
case .latency: return .newestWins
case .smooth(let buffer): return .fifo(capacity: buffer)
}
}
}
final class SessionPresenter {
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
/// default, macOS PyroWave sessions ALSO get glass gating a kernel-panic mitigation, not a
@@ -186,6 +224,7 @@ final class SessionPresenter {
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
@@ -194,31 +233,50 @@ final class SessionPresenter {
stop()
self.connection = connection
// Presenter choice the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
// on macOS see PresenterChoice.platformDefault); the settings picker is the live A/B.
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
// PresenterChoice.platformDefault deadline on iOS/tvOS, arrival on macOS overridable
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
// value is deliberately ignored). The user-facing choice is the INTENT
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
// fallback).
#if DEBUG
let allowStage1 = true
#else
let allowStage1 = false
#endif
let explicit = PresenterChoice.explicit(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
let priority = PresentPriority.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presentPriority),
bufferSetting: UserDefaults.standard.object(forKey: DefaultsKey.smoothBuffer) as? Int)
// macOS smoothness rides arrival pacing + forced vsync scheduling; under a glass-paced
// macOS session (the PyroWave DCP mitigation) the gate already serializes on the
// display, so the FIFO alone provides the buffering.
#if os(macOS)
let vsyncPaced = priority != .latency && pacing == .arrival
#else
let vsyncPaced = false
#endif
if choice != .stage1,
let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
pacing: pacing,
gateDepth: Self.gateDepth(
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"]),
storePolicy: priority.storePolicy,
vsyncPaced: vsyncPaced) {
let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
@@ -58,33 +58,121 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
private final class ReadyRing: @unchecked Sendable {
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
/// replaced the visible stage picker):
///
/// - `.newestWins` (Prioritize lowest latency, the default): a 1-slot ring the decoder
/// overwrites (drops the older undisplayed frame), the render thread takes-and-clears. Zero
/// store by construction: any deeper app-held buffer ahead of a latch-paced display becomes a
/// STANDING queue costing one full refresh per slot, forever (the depth-2 gate post-mortem
/// see SessionPresenter.gateDepth).
/// - `.fifo(capacity: K)` (Prioritize smoothness): a small deliberate jitter buffer. The
/// decoder appends; overflow drops the OLDEST (bounded added latency the newest keeps
/// flowing); the render thread pops the oldest ONE per present opportunity, so the cadence is
/// the display's. `take` withholds frames until the buffer has PREROLLED to capacity once
/// without preroll a steady stream drains every frame on arrival and headroom never builds
/// and re-arms preroll when it runs dry (an underflow: the previous frame persists on glass,
/// a repeat by omission, while headroom rebuilds). Each buffered frame one refresh interval
/// of jitter absorbed for one interval of added display latency, which the metrics SHOW
/// only the OS present floor is shaved from the HUD, never the user's chosen buffer.
///
/// Sendable; lock-guarded decoder callbacks and the render thread cross here.
public enum FrameStorePolicy: Sendable, Equatable {
case newestWins
case fifo(capacity: Int)
}
public final class FrameStore<Frame>: @unchecked Sendable {
private let lock = NSLock()
private var frame: ReadyFrame?
/// Ring submissions since the last `drainSubmitted` the decode rate for the
/// PUNKTFUNK_PRESENT_DEBUG stat line.
private let capacity: Int // 1 = newest-wins semantics
private let isFifo: Bool
private var frames: [Frame] = []
private var prerolled = false
/// Submissions since the last `drainSubmitted` the decode rate for the pf-present line.
private var submitted = 0
func submit(_ f: ReadyFrame) {
lock.lock(); frame = f; submitted += 1; lock.unlock()
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
/// runs-dry that re-armed preroll.
private var overflowDrops = 0
private var underflows = 0
public init(policy: FrameStorePolicy) {
switch policy {
case .newestWins:
capacity = 1
isFifo = false
case .fifo(let k):
capacity = max(1, k)
isFifo = true
}
}
func drainSubmitted() -> Int {
lock.lock(); defer { lock.unlock() }
let n = submitted; submitted = 0; return n
}
func take() -> ReadyFrame? {
lock.lock(); defer { lock.unlock() }
let f = frame; frame = nil; return f
}
/// Return a frame the display link took but could not present (a transient `nextDrawable`
/// failure). Kept only while the slot is still empty a newer decoded frame wins, so
/// newest-ready ordering is preserved. Without this, a failed render silently LOSES the
/// frame, and under the host's infinite GOP a static scene sends no replacement until the
/// next damage the stale picture would persist.
func putBack(_ f: ReadyFrame) {
func submit(_ f: Frame) {
lock.lock()
if frame == nil { frame = f }
if isFifo {
frames.append(f)
if frames.count > capacity {
frames.removeFirst() // oldest goes bounded latency, the newest keeps flowing
overflowDrops += 1
}
} else {
frames = [f] // newest wins; the replaced frame is the intended drop point
}
submitted += 1
lock.unlock()
}
func drainSubmitted() -> Int {
lock.lock()
defer { lock.unlock() }
let n = submitted
submitted = 0
return n
}
/// Take-and-reset the smoothness counters (the pf-present `qDrop`/`qDry` stats).
func drainSmoothing() -> (overflowDrops: Int, underflows: Int) {
lock.lock()
defer { lock.unlock() }
let out = (overflowDrops, underflows)
overflowDrops = 0
underflows = 0
return out
}
func take() -> Frame? {
lock.lock()
defer { lock.unlock() }
if isFifo {
if !prerolled {
guard frames.count >= capacity else { return nil } // still building headroom
prerolled = true
}
guard !frames.isEmpty else {
underflows += 1 // ran dry repeat by omission, rebuild headroom
prerolled = false
return nil
}
return frames.removeFirst()
}
let f = frames.first
frames.removeAll(keepingCapacity: true)
return f
}
/// 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
/// capacity+1 is trimmed by the next submit). Without this, a failed present silently LOSES
/// the frame, and under the host's infinite GOP a static scene sends no replacement until
/// the next damage the stale picture would persist.
func putBack(_ f: Frame) {
lock.lock()
if isFifo {
frames.insert(f, at: 0)
} else if frames.isEmpty {
frames = [f]
}
lock.unlock()
}
}
@@ -216,6 +304,11 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint
private let stats: PresentDebugStats?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
/// would read ~1 under direct-to-display, tracks VRR rate changes.
private let floorMeter: LatencyMeter?
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert
/// reads 1 while vendLeadMs sits at ~2 periods the scheduler ignores the request while
/// the layer is composited (the promotion hunt); reads 2 the system clamped it outright.
@@ -223,12 +316,13 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
) {
self.stash = stash
self.renderSignal = renderSignal
self.hint = hint
self.stats = stats
self.floorMeter = floorMeter
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
@@ -249,8 +343,17 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
presentLog.info("\(msg, privacy: .public)")
}
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
stats?.vendLead(
ms: (update.targetPresentationTimestamp - CACurrentMediaTime()) * 1000)
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
stats?.vendLead(ms: leadS * 1000)
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
// now lead) its 1 s p50 is the OS present floor SessionModel shaves off.
if leadS > 0, let floorMeter {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
floorMeter.record(
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
}
stash.put(update.drawable)
renderSignal.signal()
}
@@ -389,12 +492,13 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock()
}
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
lock.lock()
let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return }
last = now
let decoded = ring.drainSubmitted()
let smoothing = ring.drainSmoothing()
let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0
@@ -407,10 +511,11 @@ private final class PresentDebugStats: @unchecked Sendable {
let inflightMax = maxInFlight
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
+ "dropped=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
+ "dropped=%d qDrop=%d qDry=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
+ "vendLeadMs p50=%.2f max=%.2f",
decoded, ok, failed, empty, gated, noDrawable, dropped, maxRenderMs, inflightMax,
decoded, ok, failed, empty, gated, noDrawable, dropped,
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
@@ -453,18 +558,27 @@ private final class DecodeReport: @unchecked Sendable {
}
public final class Stage2Pipeline {
private let ring = ReadyRing()
private let ring: FrameStore<ReadyFrame>
private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder
/// Present cadence `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
/// Present cadence `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
/// SessionPresenter resolves it per session (see PresentPacing).
private let pacing: PresentPacing
/// The glass gate's in-flight present budget (`PresentGate` capacity) meaningful only under
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
private let gateDepth: Int
/// macOS smoothness: pace presents onto the vsync grid (`present(at:)` via the VsyncClock),
/// 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
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
/// The measured OS present floor (deadline pacing only): each link update's vendglass lead
/// is recorded here, and its p50 is what SessionModel subtracts from the shown display/e2e
/// numbers the pipeline-depth cost no client controls (design/apple-presentation-rebuild.md).
private let presentFloorMeter: LatencyMeter?
private let recovery = KeyframeRecovery()
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
/// binds the live connection + arming flag (see DecodeReport).
@@ -514,16 +628,22 @@ public final class Stage2Pipeline {
endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil,
pacing: PresentPacing = .arrival,
gateDepth: Int = 1
gateDepth: Int = 1,
storePolicy: FrameStorePolicy = .newestWins,
vsyncPaced: Bool = false
) {
guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter
self.pacing = pacing
self.gateDepth = gateDepth
self.vsyncPaced = vsyncPaced
self.ring = FrameStore(policy: storePolicy)
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
let ring = ring
let recovery = recovery
let renderSignal = renderSignal
@@ -723,7 +843,10 @@ public final class Stage2Pipeline {
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
// Resolved once per session.
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
let vsyncEnabled = presentMode == "vsync"
// `vsyncPaced` (macOS smoothness) FORCES vsync scheduling the FIFO store must drain
// on the display cadence, one frame per vsync, or the buffer degenerates to arrival.
let vsyncPaced = vsyncPaced
let vsyncEnabled = vsyncPaced || presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let vsyncClock = vsyncClock
@@ -732,6 +855,9 @@ public final class Stage2Pipeline {
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
let renderThread = Thread {
defer { renderStopped.signal() }
// macOS smoothness: the vsync this thread last presented onto at most ONE present
// per vsync so the FIFO drains on the display's cadence. Thread-confined.
var lastPresentTarget: CFTimeInterval = 0
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable
// without a per-iteration pool every presented frame's drawable object (plus its
@@ -741,6 +867,15 @@ public final class Stage2Pipeline {
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
// Smoothness pacing: this vsync's present slot already taken the frame stays
// in the store, and the next display-link tick re-signals. (Tolerance well under
// any refresh period; a stale clock nil target no dedup, present flows.)
if vsyncPaced, let t = vsyncClock.nextVsync(after: CACurrentMediaTime()),
abs(t - lastPresentTarget) < 0.002 {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
return
}
// Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
@@ -798,6 +933,8 @@ public final class Stage2Pipeline {
if !rendered {
gate?.release() // no present registered its handler will never fire
ring.putBack(frame)
} else if vsyncPaced, let presentAt {
lastPresentTarget = presentAt // this vsync's slot is now taken
}
debugStats?.flushIfDue(ring: ring, gate: gate)
} }
@@ -837,9 +974,11 @@ public final class Stage2Pipeline {
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
let floorMeter = presentFloorMeter
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats)
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range }
@@ -1014,7 +1153,7 @@ public final class Stage2Pipeline {
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
private static func makePyroWavePump(
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
ring: ReadyRing, renderSignal: DispatchSemaphore,
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
device: MTLDevice, queue: MTLCommandQueue,
decodeMeter: LatencyMeter?,
onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -93,6 +93,7 @@ public struct StreamView: NSViewRepresentable {
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onFrame`/`onSessionEnd` fire on the pump thread hop to the main actor for UI.
/// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust
@@ -115,7 +116,8 @@ public struct StreamView: NSViewRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil
) {
self.connection = connection
self.captureEnabled = captureEnabled
@@ -128,6 +130,7 @@ public struct StreamView: NSViewRepresentable {
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
}
public func makeNSView(context: Context) -> StreamLayerView {
@@ -138,6 +141,7 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -151,6 +155,7 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.presentFloorMeter = presentFloorMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the
@@ -172,6 +177,7 @@ public final class StreamLayerView: NSView {
var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
@@ -661,6 +667,7 @@ public final class StreamLayerView: NSView {
endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd,
@@ -61,6 +61,7 @@ public struct StreamView: UIViewControllerRepresentable {
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
private let presentFloorMeter: LatencyMeter?
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
/// captured-state D combo is detected by the macOS NSEvent monitor only); on iOS a
@@ -77,7 +78,8 @@ public struct StreamView: UIViewControllerRepresentable {
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
displayMeter: LatencyMeter? = nil,
presentFloorMeter: LatencyMeter? = nil
) {
self.connection = connection
self.captureEnabled = captureEnabled
@@ -89,6 +91,7 @@ public struct StreamView: UIViewControllerRepresentable {
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
self.presentFloorMeter = presentFloorMeter
}
public func makeUIViewController(context: Context) -> StreamViewController {
@@ -98,6 +101,7 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -110,6 +114,7 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.presentFloorMeter = presentFloorMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
if controller.connection !== connection {
@@ -145,6 +150,7 @@ public final class StreamViewController: StreamViewControllerBase {
var endToEndMeter: LatencyMeter?
var decodeMeter: LatencyMeter?
var displayMeter: LatencyMeter?
var presentFloorMeter: LatencyMeter?
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
@@ -406,6 +412,7 @@ public final class StreamViewController: StreamViewControllerBase {
endToEndMeter: endToEndMeter,
decodeMeter: decodeMeter,
displayMeter: displayMeter,
presentFloorMeter: presentFloorMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd,
@@ -50,13 +50,21 @@ 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" (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|stage4 overrides it for A/B.
/// LEGACY (2026-07 presentation rebuild design/apple-presentation-rebuild.md): the old
/// user-visible stage picker's key. No longer read the presenter is resolved from
/// `presentPriority` below; the stage ladder survives only as the
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
/// value is documented, not mysterious.
public static let presenter = "punktfunk.presenter"
/// The user's presentation intent: "latency" (default every frame shows as soon as the
/// display can; jitter appears as the occasional repeat/drop) or "smooth" (a small client
/// jitter buffer evens the cadence at the cost of added, visible display latency).
/// Resolved once per session by SessionPresenter see PresentPriority.
public static let presentPriority = "punktfunk.presentPriority"
/// Smoothness's jitter-buffer capacity in frames: 0 = Automatic (currently 2), or 13.
/// Each buffered frame adds ~one refresh interval of display latency and absorbs ~one
/// interval of arrival jitter. Only meaningful when `presentPriority` is "smooth".
public static let smoothBuffer = "punktfunk.smoothBuffer"
/// 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
/// (lowest latency the default, OFF). Resolved once per session;
@@ -75,6 +75,103 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0)
}
// MARK: - PresentPriority (the user-facing latency/smoothness intent)
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
func testPresentPriorityResolution() {
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
.smooth(buffer: 2), "unset buffer = Automatic = 2")
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
XCTAssertEqual(
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
.smooth(buffer: 2), "out-of-range buffer = Automatic")
}
/// The intentstore mapping: latency runs the zero-queue newest-wins slot, smoothness the
/// FIFO jitter buffer at the resolved capacity.
func testPresentPriorityStorePolicy() {
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
XCTAssertEqual(
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
}
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
/// restores only into an empty slot the exact pre-rebuild ReadyRing semantics.
func testFrameStoreNewestWins() {
let store = FrameStore<Int>(policy: .newestWins)
XCTAssertNil(store.take())
store.submit(1)
store.submit(2)
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
XCTAssertNil(store.take())
store.putBack(7)
store.submit(8) // a fresh decode beats the putBack
store.putBack(7)
XCTAssertEqual(store.take(), 8)
XCTAssertEqual(store.drainSubmitted(), 3)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 0)
XCTAssertEqual(smoothing.underflows, 0)
}
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity
/// without preroll a steady stream drains on arrival and headroom never builds then pops
/// oldest-first.
func testFrameStoreFifoPrerollsToCapacity() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
store.submit(2)
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
store.submit(3)
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
XCTAssertEqual(store.take(), 3)
}
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
func testFrameStoreFifoOverflowAndUnderflow() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
store.submit(3) // full 1 (the oldest) goes
XCTAssertEqual(store.take(), 2)
XCTAssertEqual(store.take(), 3)
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
store.submit(4)
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
store.submit(5)
XCTAssertEqual(store.take(), 4)
let smoothing = store.drainSmoothing()
XCTAssertEqual(smoothing.overflowDrops, 1)
XCTAssertEqual(smoothing.underflows, 1)
}
/// FIFO putBack reinserts at the FRONT a frame the render thread couldn't present is
/// still the oldest, so present order is preserved.
func testFrameStoreFifoPutBackPreservesOrder() {
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
store.submit(1)
store.submit(2)
let f = store.take()
XCTAssertEqual(f, 1)
store.putBack(f!)
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
XCTAssertEqual(store.take(), 2)
}
// MARK: - LatestBox (stage-4's drawable hand-off)
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
@@ -106,15 +203,13 @@ final class PresentPacingTests: XCTestCase {
// MARK: - PresenterChoice
/// The platform default: deadline-paced stage-4 on iOS/iPadOS (the vsync-latching platform
/// where any bounded-FIFO pacing keeps a standing queue), glass stage-3 on tvOS (proven
/// stage-4 A/Bs first), arrival stage-2 on macOS (sync-off presents don't queue). No
/// selection / garbage falls back to it.
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
/// platforms where any bounded-FIFO pacing keeps a standing queue tvOS joined in the
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
/// No selection / garbage falls back to it.
func testPresenterChoiceFallsBackToPlatformDefault() {
#if os(iOS)
#if os(iOS) || os(tvOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
#elseif os(tvOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
#else
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
#endif