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,
|
endToEndMeter: model.endToEnd,
|
||||||
decodeMeter: model.decodeStage,
|
decodeMeter: model.decodeStage,
|
||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage,
|
||||||
|
presentFloorMeter: model.presentFloor
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
// The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
|
// 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 decodeValid = false
|
||||||
@Published var displayP50Ms = 0.0
|
@Published var displayP50Ms = 0.0
|
||||||
@Published var displayValid = false
|
@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
|
/// 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.
|
/// share of frames offered, `lost/(received+lost)`. The HUD hides the line while zero.
|
||||||
@Published var lostFrames = 0
|
@Published var lostFrames = 0
|
||||||
@@ -133,6 +147,9 @@ final class SessionModel: ObservableObject {
|
|||||||
let endToEnd = LatencyMeter()
|
let endToEnd = LatencyMeter()
|
||||||
let decodeStage = LatencyMeter()
|
let decodeStage = LatencyMeter()
|
||||||
let displayStage = 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).
|
/// Cumulative reassembler-drop counter at the last stats drain (per-window `lost` delta).
|
||||||
private var lastFramesDropped: UInt64 = 0
|
private var lastFramesDropped: UInt64 = 0
|
||||||
private var statsTimer: Timer?
|
private var statsTimer: Timer?
|
||||||
@@ -472,6 +489,7 @@ final class SessionModel: ObservableObject {
|
|||||||
endToEndValid = false
|
endToEndValid = false
|
||||||
decodeValid = false
|
decodeValid = false
|
||||||
displayValid = false
|
displayValid = false
|
||||||
|
osFloorValid = false
|
||||||
lostFrames = 0
|
lostFrames = 0
|
||||||
lostPct = 0
|
lostPct = 0
|
||||||
mouseCaptured = false
|
mouseCaptured = false
|
||||||
@@ -655,15 +673,25 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
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,
|
// 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;
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
// `presents` counts frames that reached glass (the display meter's sample count)
|
// `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
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
// upstream (host capture/encode or the network).
|
// upstream (host capture/encode or the network).
|
||||||
if frames > 0 {
|
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(
|
let line = String(
|
||||||
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
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,
|
frames,
|
||||||
displayWindow?.count ?? 0,
|
displayWindow?.count ?? 0,
|
||||||
self.endToEndValid ? self.endToEndP50Ms : -1,
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
@@ -671,7 +699,10 @@ final class SessionModel: ObservableObject {
|
|||||||
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
self.decodeValid ? self.decodeP50Ms : -1,
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
self.displayValid ? self.displayP50Ms : -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)")
|
statsLog.info("\(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ struct StreamHUDView: View {
|
|||||||
private var compactLine: String {
|
private var compactLine: String {
|
||||||
var parts = ["\(model.fps) fps"]
|
var parts = ["\(model.fps) fps"]
|
||||||
if model.endToEndValid {
|
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 {
|
} else if model.hostNetworkValid {
|
||||||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||||||
}
|
}
|
||||||
@@ -86,24 +88,36 @@ struct StreamHUDView: View {
|
|||||||
}
|
}
|
||||||
if model.endToEndValid {
|
if model.endToEndValid {
|
||||||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
// 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.
|
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
// 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))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation (detailed tier only): the stages tiling the headline interval
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// (per-window p50s — they only approximately sum to the directly-measured
|
// (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
|
// 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 verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
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))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} 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))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
} else if model.hostNetworkValid {
|
||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// 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.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = 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)
|
#if os(iOS)
|
||||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||||
#endif
|
#endif
|
||||||
@@ -288,11 +290,19 @@ struct GamepadSettingsView: View {
|
|||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||||
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||||
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||||
options: SettingsOptions.presenters, current: presenter
|
+ "from the next session.",
|
||||||
) { presenter = $0 },
|
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(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
|
|||||||
@@ -37,35 +37,31 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Present-pacing choices (`DefaultsKey.presenter` — see SessionPresenter's PresenterChoice):
|
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||||
/// stage-2 arrival, stage-3 glass-gated, stage-4 deadline (CAMetalDisplayLink — iOS/tvOS
|
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||||
/// only; macOS resolves it back to its default, so it isn't offered there). The freeze-prone
|
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||||
/// stage-1 diagnostic only ships in DEBUG builds.
|
/// PUNKTFUNK_PRESENTER debug env lever.
|
||||||
static var presenters: [(label: String, tag: String)] {
|
static let presentPriorities: [(label: String, tag: String)] = [
|
||||||
var options: [(label: String, tag: String)] = [
|
("Lowest latency", "latency"),
|
||||||
("Stage 2", "stage2"),
|
("Smoothness", "smooth"),
|
||||||
("Stage 3", "stage3"),
|
|
||||||
]
|
]
|
||||||
#if !os(macOS)
|
static let presentPriorityDefault = "latency"
|
||||||
options.append(("Stage 4", "stage4"))
|
|
||||||
#endif
|
|
||||||
#if DEBUG
|
|
||||||
options.append(("Stage 1 (debug)", "stage1"))
|
|
||||||
#endif
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — iOS/iPadOS
|
/// Smoothness's jitter-buffer sizes (`DefaultsKey.smoothBuffer`; 0 = Automatic, currently 2
|
||||||
/// runs deadline pacing, tvOS glass, macOS arrival). Views seed their @AppStorage display
|
/// frames). The ms hints derive from the chosen refresh setting — each buffered frame costs
|
||||||
/// from this so an untouched picker shows what actually runs.
|
/// about one refresh interval of display latency and absorbs about one interval of arrival
|
||||||
static var presenterDefault: String {
|
/// jitter.
|
||||||
#if os(iOS)
|
static func smoothBuffers(refreshHz: Int) -> [(label: String, tag: Int)] {
|
||||||
"stage4"
|
let periodMs = 1000.0 / Double(max(24, refreshHz))
|
||||||
#elseif os(tvOS)
|
func hint(_ frames: Int) -> String {
|
||||||
"stage3"
|
String(format: "+%.0f ms", Double(frames) * periodMs)
|
||||||
#else
|
}
|
||||||
"stage2"
|
return [
|
||||||
#endif
|
("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.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
|||||||
@@ -412,34 +412,34 @@ extension SettingsView {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// The present-pacing ladder: stage-2 (Metal/VTDecompressionSession, present on frame
|
// The presentation intent (design/apple-presentation-rebuild.md — replaced the visible
|
||||||
// arrival), stage-3 (glass-gated), stage-4 (CAMetalDisplayLink deadline pacing — iOS/tvOS
|
// stage picker): latency (newest-wins, zero queue — the configuration the 2026-07 pacing
|
||||||
// only). The default is per-platform — deadline on iOS, glass on tvOS, arrival on macOS
|
// saga optimized) vs smoothness (a small deliberate jitter buffer, size user-tunable). The
|
||||||
// (see Stage2Pipeline's PresentPacing for the queue-saturation rationale) — so the
|
// stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
||||||
// "(default)" marker rides presenterDefault instead of a hardcoded row. Stage-1 (compressed
|
@ViewBuilder var presentationSection: some View {
|
||||||
// video straight to the system layer) stays a DEBUG-only diagnostic — it freezes hard on a
|
|
||||||
// lost HEVC reference.
|
|
||||||
@ViewBuilder var presenterSection: some View {
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Presenter", selection: $presenter) {
|
Picker("Prioritize", selection: $presentPriority) {
|
||||||
ForEach(SettingsOptions.presenters, id: \.tag) { option in
|
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
||||||
Text(option.tag == SettingsOptions.presenterDefault
|
Text(option.tag == SettingsOptions.presentPriorityDefault
|
||||||
? "\(option.label) (default)" : option.label)
|
? "\(option.label) (default)" : option.label)
|
||||||
.tag(option.tag)
|
.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: {
|
} header: {
|
||||||
Text("Video presenter")
|
Text("Presentation")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
|
Text("Lowest latency shows every frame the moment the display can take it — "
|
||||||
+ "running near the stream's frame rate, queued frames can add two to three "
|
+ "network hiccups appear as the occasional repeated or skipped frame. "
|
||||||
+ "refreshes of display latency that never drains. Stage 3: presents wait for "
|
+ "Smoothness holds a small buffer of frames to even out those hiccups, at the "
|
||||||
+ "the previous frame to reach the glass — a strict cap on undisplayed frames, "
|
+ "cost of the buffer's worth of added display latency; the buffer size sets "
|
||||||
+ "always the freshest, dropping late frames instead of queueing them. "
|
+ "that trade. Applies from the next session.")
|
||||||
+ "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.")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 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)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
#endif
|
#endif
|
||||||
@@ -131,7 +133,7 @@ struct SettingsView: View {
|
|||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
presentationSection
|
||||||
hdrSection
|
hdrSection
|
||||||
vrrSection
|
vrrSection
|
||||||
vsyncSection
|
vsyncSection
|
||||||
@@ -264,7 +266,7 @@ struct SettingsView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
case .display:
|
case .display:
|
||||||
Form {
|
Form {
|
||||||
presenterSection
|
presentationSection
|
||||||
hdrSection
|
hdrSection
|
||||||
vrrSection
|
vrrSection
|
||||||
statisticsSection
|
statisticsSection
|
||||||
@@ -369,9 +371,15 @@ struct SettingsView: View {
|
|||||||
title: "Compositor", options: SettingsOptions.compositors,
|
title: "Compositor", options: SettingsOptions.compositors,
|
||||||
selection: $compositor)
|
selection: $compositor)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter",
|
title: "Prioritize",
|
||||||
options: SettingsOptions.presenters,
|
options: SettingsOptions.presentPriorities,
|
||||||
selection: $presenter)
|
selection: $presentPriority)
|
||||||
|
if presentPriority == "smooth" {
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Smoothness buffer",
|
||||||
|
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
||||||
|
selection: $smoothBuffer)
|
||||||
|
}
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
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
|
// 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
|
// (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
|
// stage-4 on iOS/tvOS, arrival-paced stage-2 on macOS (see PresenterChoice.platformDefault);
|
||||||
// PresenterChoice.platformDefault); stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the
|
// the user-facing choice is the INTENT (PresentPriority: latency vs smoothness+buffer — the
|
||||||
// Metal-unavailable / DEBUG fallback. The views own the platform bits — capture, window/scale
|
// 2026-07 rebuild, design/apple-presentation-rebuild.md), the stage ladder is env-only debug.
|
||||||
// tracking, and constructing the display link (arrival/glass pacing only; deadline pacing runs
|
// Stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG fallback.
|
||||||
// its own CAMetalDisplayLink) — and delegate the shared presenter lifecycle here.
|
// 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.
|
// 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
|
/// iOS/iPadOS/tvOS default to DEADLINE pacing (stage-4), macOS to arrival (stage-2).
|
||||||
/// arrival (stage-2).
|
|
||||||
///
|
///
|
||||||
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
|
/// 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
|
/// (`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
|
/// drawable per refresh, presented the moment a frame decodes — the queue cannot exist and
|
||||||
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
/// nothing waits on callbacks (see `PresentPacing.deadline`).
|
||||||
///
|
///
|
||||||
/// tvOS stays on its proven stage-3 until stage-4 gets an on-glass A/B there
|
/// tvOS joined iOS on the deadline engine in the 2026-07 presentation rebuild
|
||||||
/// (`PUNKTFUNK_PRESENTER=stage4`). macOS keeps stage-2: with the layer's sync off, presents
|
/// (design/apple-presentation-rebuild.md — the engine is field-proven on iOS and strictly
|
||||||
/// are out-of-band flips that don't queue, so arrival is genuinely lowest-latency there.
|
/// 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 {
|
static var platformDefault: PresenterChoice {
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
.stage4
|
.stage4
|
||||||
#elseif os(tvOS)
|
|
||||||
.stage3
|
|
||||||
#else
|
#else
|
||||||
.stage2
|
.stage2
|
||||||
#endif
|
#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 {
|
final class SessionPresenter {
|
||||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
/// 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
|
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
||||||
@@ -186,6 +224,7 @@ final class SessionPresenter {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil,
|
||||||
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
onSessionEnd: (@Sendable () -> Void)?,
|
onSessionEnd: (@Sendable () -> Void)?,
|
||||||
@@ -194,31 +233,50 @@ final class SessionPresenter {
|
|||||||
stop()
|
stop()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
// Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession
|
// Presentation resolution (design/apple-presentation-rebuild.md). The Metal pipeline is
|
||||||
// decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder
|
// the DEFAULT (explicit VTDecompressionSession decode + a CAMetalLayer present): it can
|
||||||
// where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which
|
// detect + recover a wedged decoder where stage-1's AVSampleBufferDisplayLayer freezes
|
||||||
// pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2
|
// hard on a lost HEVC reference. The MECHANISM (pacing) is per-platform via
|
||||||
// on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B.
|
// PresenterChoice.platformDefault — deadline on iOS/tvOS, arrival on macOS — overridable
|
||||||
// Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the
|
// only by the hidden PUNKTFUNK_PRESENTER debug env (the legacy persisted stage picker
|
||||||
// default (the stage-1 pump below stays the automatic fallback if Metal is missing).
|
// 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
|
#if DEBUG
|
||||||
let allowStage1 = true
|
let allowStage1 = true
|
||||||
#else
|
#else
|
||||||
let allowStage1 = false
|
let allowStage1 = false
|
||||||
#endif
|
#endif
|
||||||
let explicit = PresenterChoice.explicit(
|
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"],
|
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
|
||||||
allowStage1: allowStage1)
|
allowStage1: allowStage1)
|
||||||
let choice = explicit ?? PresenterChoice.platformDefault
|
let choice = explicit ?? PresenterChoice.platformDefault
|
||||||
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
|
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,
|
if choice != .stage1,
|
||||||
let pipeline = Stage2Pipeline(
|
let pipeline = Stage2Pipeline(
|
||||||
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
|
presentFloorMeter: presentFloorMeter,
|
||||||
pacing: pacing,
|
pacing: pacing,
|
||||||
gateDepth: Self.gateDepth(
|
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
|
let metal = pipeline.layer
|
||||||
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
|
||||||
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
|
// 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.
|
/// 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")
|
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
|
||||||
|
|
||||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
|
||||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
/// user's presentation intent (design/apple-presentation-rebuild.md — the 2026-07 rebuild that
|
||||||
private final class ReadyRing: @unchecked Sendable {
|
/// 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 let lock = NSLock()
|
||||||
private var frame: ReadyFrame?
|
private let capacity: Int // 1 = newest-wins semantics
|
||||||
/// Ring submissions since the last `drainSubmitted` — the decode rate for the
|
private let isFifo: Bool
|
||||||
/// PUNKTFUNK_PRESENT_DEBUG stat line.
|
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
|
private var submitted = 0
|
||||||
func submit(_ f: ReadyFrame) {
|
/// Smoothness accounting for the pf-present line: frames dropped by a full buffer, and
|
||||||
lock.lock(); frame = f; submitted += 1; lock.unlock()
|
/// 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() }
|
func submit(_ f: Frame) {
|
||||||
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) {
|
|
||||||
lock.lock()
|
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()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +304,11 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|||||||
private let renderSignal: DispatchSemaphore
|
private let renderSignal: DispatchSemaphore
|
||||||
private let hint: FrameRateHint
|
private let hint: FrameRateHint
|
||||||
private let stats: PresentDebugStats?
|
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 —
|
/// 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
|
/// 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.
|
/// 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(
|
init(
|
||||||
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
|
||||||
hint: FrameRateHint, stats: PresentDebugStats?
|
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
|
||||||
) {
|
) {
|
||||||
self.stash = stash
|
self.stash = stash
|
||||||
self.renderSignal = renderSignal
|
self.renderSignal = renderSignal
|
||||||
self.hint = hint
|
self.hint = hint
|
||||||
self.stats = stats
|
self.stats = stats
|
||||||
|
self.floorMeter = floorMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
|
||||||
@@ -249,8 +343,17 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
|||||||
presentLog.info("\(msg, privacy: .public)")
|
presentLog.info("\(msg, privacy: .public)")
|
||||||
}
|
}
|
||||||
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
||||||
stats?.vendLead(
|
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
|
||||||
ms: (update.targetPresentationTimestamp - CACurrentMediaTime()) * 1000)
|
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)
|
stash.put(update.drawable)
|
||||||
renderSignal.signal()
|
renderSignal.signal()
|
||||||
}
|
}
|
||||||
@@ -389,12 +492,13 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
|
func flushIfDue(ring: FrameStore<ReadyFrame>, gate: PresentGate?) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let now = CACurrentMediaTime()
|
let now = CACurrentMediaTime()
|
||||||
guard now - last >= 1 else { lock.unlock(); return }
|
guard now - last >= 1 else { lock.unlock(); return }
|
||||||
last = now
|
last = now
|
||||||
let decoded = ring.drainSubmitted()
|
let decoded = ring.drainSubmitted()
|
||||||
|
let smoothing = ring.drainSmoothing()
|
||||||
let deltas = glassDeltasMs.sorted()
|
let deltas = glassDeltasMs.sorted()
|
||||||
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
|
||||||
let dMax = deltas.last ?? 0
|
let dMax = deltas.last ?? 0
|
||||||
@@ -407,10 +511,11 @@ private final class PresentDebugStats: @unchecked Sendable {
|
|||||||
let inflightMax = maxInFlight
|
let inflightMax = maxInFlight
|
||||||
let line = String(
|
let line = String(
|
||||||
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
|
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 "
|
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
|
||||||
+ "vendLeadMs 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,
|
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
||||||
vendP50, vendMax)
|
vendP50, vendMax)
|
||||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
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 {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring: FrameStore<ReadyFrame>
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
/// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
|
/// Present cadence — `.arrival` (stage-2), `.glass` (stage-3, the present gate) or
|
||||||
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
|
/// `.deadline` (stage-4, the CAMetalDisplayLink engine). Fixed for the pipeline's lifetime;
|
||||||
|
/// SessionPresenter resolves it per session (see PresentPacing).
|
||||||
private let pacing: PresentPacing
|
private let pacing: PresentPacing
|
||||||
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
/// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under
|
||||||
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
/// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`).
|
||||||
private let gateDepth: Int
|
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 endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: 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()
|
private let recovery = KeyframeRecovery()
|
||||||
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
|
||||||
/// binds the live connection + arming flag (see DecodeReport).
|
/// binds the live connection + arming flag (see DecodeReport).
|
||||||
@@ -514,16 +628,22 @@ public final class Stage2Pipeline {
|
|||||||
endToEndMeter: LatencyMeter?,
|
endToEndMeter: LatencyMeter?,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil,
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil,
|
||||||
pacing: PresentPacing = .arrival,
|
pacing: PresentPacing = .arrival,
|
||||||
gateDepth: Int = 1
|
gateDepth: Int = 1,
|
||||||
|
storePolicy: FrameStorePolicy = .newestWins,
|
||||||
|
vsyncPaced: Bool = false
|
||||||
) {
|
) {
|
||||||
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
guard let presenter = MetalVideoPresenter.make() else { return nil }
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.pacing = pacing
|
self.pacing = pacing
|
||||||
self.gateDepth = gateDepth
|
self.gateDepth = gateDepth
|
||||||
|
self.vsyncPaced = vsyncPaced
|
||||||
|
self.ring = FrameStore(policy: storePolicy)
|
||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
let recovery = recovery
|
let recovery = recovery
|
||||||
let renderSignal = renderSignal
|
let renderSignal = renderSignal
|
||||||
@@ -723,7 +843,10 @@ public final class Stage2Pipeline {
|
|||||||
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
// lowest-latency behavior); PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B.
|
||||||
// Resolved once per session.
|
// Resolved once per session.
|
||||||
let presentMode = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_MODE"]
|
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"
|
|| (presentMode != "immediate"
|
||||||
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
|
||||||
let vsyncClock = vsyncClock
|
let vsyncClock = vsyncClock
|
||||||
@@ -732,6 +855,9 @@ public final class Stage2Pipeline {
|
|||||||
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
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`):
|
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||||
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||||
// without a per-iteration pool every presented frame's drawable object (plus its
|
// 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)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
return
|
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
|
// 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
|
// 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
|
// 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 {
|
if !rendered {
|
||||||
gate?.release() // no present registered — its handler will never fire
|
gate?.release() // no present registered — its handler will never fire
|
||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
|
} else if vsyncPaced, let presentAt {
|
||||||
|
lastPresentTarget = presentAt // this vsync's slot is now taken
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
} }
|
} }
|
||||||
@@ -837,9 +974,11 @@ public final class Stage2Pipeline {
|
|||||||
let layer = presenter.layer
|
let layer = presenter.layer
|
||||||
let stash = LatestBox<CAMetalDrawable>()
|
let stash = LatestBox<CAMetalDrawable>()
|
||||||
|
|
||||||
|
let floorMeter = presentFloorMeter
|
||||||
let linkThread = Thread {
|
let linkThread = Thread {
|
||||||
let delegate = DeadlineLinkDelegate(
|
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)
|
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||||
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
|
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
|
||||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
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).
|
/// reason the VT pump avoids capturing `self` (a missed stop must not leak a live pipeline).
|
||||||
private static func makePyroWavePump(
|
private static func makePyroWavePump(
|
||||||
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
connection: PunktfunkConnection, token: StopFlag, pumpStopped: DispatchSemaphore,
|
||||||
ring: ReadyRing, renderSignal: DispatchSemaphore,
|
ring: FrameStore<ReadyFrame>, renderSignal: DispatchSemaphore,
|
||||||
device: MTLDevice, queue: MTLCommandQueue,
|
device: MTLDevice, queue: MTLCommandQueue,
|
||||||
decodeMeter: LatencyMeter?,
|
decodeMeter: LatencyMeter?,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
private let presentFloorMeter: LatencyMeter?
|
||||||
|
|
||||||
/// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI.
|
/// `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
|
/// `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,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -128,6 +130,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeNSView(context: Context) -> StreamLayerView {
|
public func makeNSView(context: Context) -> StreamLayerView {
|
||||||
@@ -138,6 +141,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.presentFloorMeter = presentFloorMeter
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -151,6 +155,7 @@ public struct StreamView: NSViewRepresentable {
|
|||||||
view.endToEndMeter = endToEndMeter
|
view.endToEndMeter = endToEndMeter
|
||||||
view.decodeMeter = decodeMeter
|
view.decodeMeter = decodeMeter
|
||||||
view.displayMeter = displayMeter
|
view.displayMeter = displayMeter
|
||||||
|
view.presentFloorMeter = presentFloorMeter
|
||||||
view.onResizeTarget = onResizeTarget
|
view.onResizeTarget = onResizeTarget
|
||||||
view.onDecodedSize = onDecodedSize
|
view.onDecodedSize = onDecodedSize
|
||||||
// SwiftUI reuses the NSView across state changes — repoint the pump only when the
|
// 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 endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
|
var presentFloorMeter: LatencyMeter?
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -661,6 +667,7 @@ public final class StreamLayerView: NSView {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
|
presentFloorMeter: presentFloorMeter,
|
||||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
onSessionEnd: onSessionEnd,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
private let endToEndMeter: LatencyMeter?
|
private let endToEndMeter: LatencyMeter?
|
||||||
private let decodeMeter: LatencyMeter?
|
private let decodeMeter: LatencyMeter?
|
||||||
private let displayMeter: LatencyMeter?
|
private let displayMeter: LatencyMeter?
|
||||||
|
private let presentFloorMeter: LatencyMeter?
|
||||||
|
|
||||||
/// `onDisconnectRequest` exists for call-site parity with the macOS StreamView (the
|
/// `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
|
/// 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,
|
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
|
||||||
endToEndMeter: LatencyMeter? = nil,
|
endToEndMeter: LatencyMeter? = nil,
|
||||||
decodeMeter: LatencyMeter? = nil,
|
decodeMeter: LatencyMeter? = nil,
|
||||||
displayMeter: LatencyMeter? = nil
|
displayMeter: LatencyMeter? = nil,
|
||||||
|
presentFloorMeter: LatencyMeter? = nil
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.captureEnabled = captureEnabled
|
self.captureEnabled = captureEnabled
|
||||||
@@ -89,6 +91,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
self.endToEndMeter = endToEndMeter
|
self.endToEndMeter = endToEndMeter
|
||||||
self.decodeMeter = decodeMeter
|
self.decodeMeter = decodeMeter
|
||||||
self.displayMeter = displayMeter
|
self.displayMeter = displayMeter
|
||||||
|
self.presentFloorMeter = presentFloorMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
public func makeUIViewController(context: Context) -> StreamViewController {
|
public func makeUIViewController(context: Context) -> StreamViewController {
|
||||||
@@ -98,6 +101,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.presentFloorMeter = presentFloorMeter
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -110,6 +114,7 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
controller.endToEndMeter = endToEndMeter
|
controller.endToEndMeter = endToEndMeter
|
||||||
controller.decodeMeter = decodeMeter
|
controller.decodeMeter = decodeMeter
|
||||||
controller.displayMeter = displayMeter
|
controller.displayMeter = displayMeter
|
||||||
|
controller.presentFloorMeter = presentFloorMeter
|
||||||
controller.onResizeTarget = onResizeTarget
|
controller.onResizeTarget = onResizeTarget
|
||||||
controller.onDecodedSize = onDecodedSize
|
controller.onDecodedSize = onDecodedSize
|
||||||
if controller.connection !== connection {
|
if controller.connection !== connection {
|
||||||
@@ -145,6 +150,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
var endToEndMeter: LatencyMeter?
|
var endToEndMeter: LatencyMeter?
|
||||||
var decodeMeter: LatencyMeter?
|
var decodeMeter: LatencyMeter?
|
||||||
var displayMeter: LatencyMeter?
|
var displayMeter: LatencyMeter?
|
||||||
|
var presentFloorMeter: LatencyMeter?
|
||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
@@ -406,6 +412,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
endToEndMeter: endToEndMeter,
|
endToEndMeter: endToEndMeter,
|
||||||
decodeMeter: decodeMeter,
|
decodeMeter: decodeMeter,
|
||||||
displayMeter: displayMeter,
|
displayMeter: displayMeter,
|
||||||
|
presentFloorMeter: presentFloorMeter,
|
||||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||||
onFrame: onFrame,
|
onFrame: onFrame,
|
||||||
onSessionEnd: onSessionEnd,
|
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
|
/// 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.
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
public static let micChannel = "punktfunk.micChannel"
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
/// Which presenter runs a session: "stage2" (explicit decode + Metal present on frame
|
/// LEGACY (2026-07 presentation rebuild — design/apple-presentation-rebuild.md): the old
|
||||||
/// arrival — the macOS default), "stage3" (same pipeline, glass-gated present pacing — the
|
/// user-visible stage picker's key. No longer read — the presenter is resolved from
|
||||||
/// tvOS default), "stage4" (CAMetalDisplayLink deadline pacing, iOS/tvOS only — the iOS
|
/// `presentPriority` below; the stage ladder survives only as the
|
||||||
/// default; see Stage2Pipeline's PresentPacing for the ladder), or "stage1" (DEBUG-only
|
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 debug env lever. Kept so a synced old
|
||||||
/// system-layer fallback). Resolved once per session by SessionPresenter;
|
/// value is documented, not mysterious.
|
||||||
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 overrides it for A/B.
|
|
||||||
public static let presenter = "punktfunk.presenter"
|
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
|
/// 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
|
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
|
||||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||||
|
|||||||
@@ -75,6 +75,103 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
XCTAssertEqual(gate.drainForced(), 0)
|
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)
|
// MARK: - LatestBox (stage-4's drawable hand-off)
|
||||||
|
|
||||||
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
|
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
|
||||||
@@ -106,15 +203,13 @@ final class PresentPacingTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - PresenterChoice
|
// MARK: - PresenterChoice
|
||||||
|
|
||||||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS (the vsync-latching platform
|
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
||||||
/// where any bounded-FIFO pacing keeps a standing queue), glass stage-3 on tvOS (proven —
|
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
||||||
/// stage-4 A/Bs first), arrival stage-2 on macOS (sync-off presents don't queue). No
|
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
||||||
/// selection / garbage falls back to it.
|
/// No selection / garbage falls back to it.
|
||||||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
||||||
#elseif os(tvOS)
|
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
|
|
||||||
#else
|
#else
|
||||||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user