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:
@@ -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 vend→glass 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 presents≪fps 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 (capture→on-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` ∈ 1…3;
|
||||
/// 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 vend→glass
|
||||
/// 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 vend→glass 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 N→stereo 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 1…3.
|
||||
/// 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 intent→store 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
|
||||
|
||||
Reference in New Issue
Block a user