From 8a40e46706aadba75730d95662f0fa6ca704fa6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 15:42:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple):=20presentation=20rebuild=20?= =?UTF-8?q?=E2=80=94=20intent-based=20presenter=20+=20honest-floor=20metri?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Sources/PunktfunkClient/ContentView.swift | 3 +- .../Session/SessionModel.swift | 35 ++- .../Session/StreamHUDView.swift | 26 ++- .../Settings/GamepadSettingsView.swift | 22 +- .../Settings/SettingsOptions.swift | 52 ++--- .../Settings/SettingsView+Sections.swift | 42 ++-- .../Settings/SettingsView.swift | 20 +- .../PunktfunkKit/Video/SessionPresenter.swift | 102 +++++++-- .../PunktfunkKit/Video/Stage2Pipeline.swift | 211 +++++++++++++++--- .../PunktfunkKit/Views/StreamView.swift | 9 +- .../PunktfunkKit/Views/StreamViewIOS.swift | 9 +- .../PunktfunkShared/DefaultsKeys.swift | 20 +- .../PresentPacingTests.swift | 109 ++++++++- 13 files changed, 517 insertions(+), 143 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 08961694..8f963ac7 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index f03c8e22..a60c5256 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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)") } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 72489a6c..b53ccdcb 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index e306fc68..4eedc6ee 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -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", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 340d27f2..9323ce14 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 12656d36..1fa5c665 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -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) } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index cda632eb..d164c146 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 476abffa..77e060fb 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -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(). diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 23406643..b1996ec8 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -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: @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, 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, 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 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() + 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, renderSignal: DispatchSemaphore, device: MTLDevice, queue: MTLCommandQueue, decodeMeter: LatencyMeter?, onFrame: (@Sendable (AccessUnit) -> Void)?, diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index b0d7b14b..fa448a84 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -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, diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 57af2f3e..6107d9e8 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -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, diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index f75da1c6..6d21ed04 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -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; diff --git a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift index 1f2e1b4c..39f3e073 100644 --- a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift @@ -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(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(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(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(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