Skip to content

Commit 7b1e5ca

Browse files
joeblauclaude
andauthored
feat: dropped-frame / achieved-fps telemetry (fixes #23) (#34)
Instrument the four previously-silent frame-shed sites — PTS-deadline pacing, bufferingNewest(1) backpressure, compositor pool exhaustion, and VideoFrameAdmission — plus the encoded-frame rate, so the M3 HUD and the M1/M9 adaptive logic finally have real measured observability instead of only the encoder's targets. - FrameTelemetry (StreamCore): thread-safe cumulative counters (OSAllocatedUnfairLock, mirroring VideoFrameAdmission) and a pure, unit-tested achieved-fps / congestion-drops-per-second rate function. - LiveStats gains achievedFrameRate + droppedFrames. Congestion drops = backpressure + admission (the frames the pipeline wanted to encode but couldn't); intentional pacing and overlay-only compositor drops are tracked but excluded. - The capture output records captured/pacing/backpressure/compositor on the sample queue; both publishers record admission + encoded and hold the shared telemetry so the adaptive controller (M9) can consume the congestion signal. - The controller folds measured fps + cumulative drops into liveStats each ~1s poll; the Settings HUD shows achieved fps and a "N frames dropped" notice that appears only under real congestion. Tests (StreamCore, CI-testable): FrameTelemetry counters + rate math and the new LiveStats formatting/equality. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d05f843 commit 7b1e5ca

8 files changed

Lines changed: 501 additions & 35 deletions

File tree

Stream/ScreenCaptureController.swift

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ final class ScreenCaptureController: NSObject {
4242
/// ~1s telemetry poll, live only. Cancelled on every teardown path (they all
4343
/// funnel through `stopCapture`).
4444
@ObservationIgnored private var statsTask: Task<Void, Never>?
45+
/// Shared frame-drop / achieved-fps counters (issue #23 / M8), created per
46+
/// broadcast and handed to BOTH the capture output and the publisher so the four
47+
/// shed sites and the encoded rate land in one place. Read once per stats poll to
48+
/// fold measured fps + congestion drops into `liveStats`.
49+
@ObservationIgnored private var frameTelemetry: FrameTelemetry?
50+
/// The previous telemetry snapshot + its capture time, so each poll derives
51+
/// achieved fps from the delta over the real elapsed window.
52+
@ObservationIgnored private var lastTelemetrySnapshot: FrameTelemetrySnapshot?
53+
@ObservationIgnored private var lastTelemetryAt: UInt64 = 0
4554

4655
override init() {
4756
super.init()
@@ -121,24 +130,56 @@ final class ScreenCaptureController: NSObject {
121130
// Write through INCLUDING nil: the publishers return nil during a
122131
// reconnect, which must clear the card to its "—" placeholders rather
123132
// than freezing on stale last-good telemetry while the stream is down.
124-
self?.updateLiveStats(snapshot)
133+
self?.applyPolledStats(snapshot)
125134
try? await Task.sleep(for: .seconds(1))
126135
}
127136
}
128137
}
129138

139+
/// One stats tick: fold the measured frame telemetry (achieved fps + cumulative
140+
/// congestion drops) into the publisher's target-based snapshot, then store it.
141+
/// Advances the achieved-fps delta baseline as a side effect, so it must run
142+
/// exactly once per poll.
143+
private func applyPolledStats(_ base: LiveStats?) {
144+
updateLiveStats(enrichWithTelemetry(base))
145+
}
146+
147+
/// Merges the shared `FrameTelemetry` into the publisher's snapshot: the measured
148+
/// achieved fps (from the encoded-frame delta over the real elapsed window) and
149+
/// the cumulative congestion drops. Returns `base` untouched when telemetry isn't
150+
/// available (pre-live / torn down). Mutates the delta baseline.
151+
private func enrichWithTelemetry(_ base: LiveStats?) -> LiveStats? {
152+
guard let telemetry = frameTelemetry else { return base }
153+
let current = telemetry.snapshot()
154+
let now = DispatchTime.now().uptimeNanoseconds
155+
var achieved = 0
156+
if let previous = lastTelemetrySnapshot, lastTelemetryAt > 0, now > lastTelemetryAt {
157+
let elapsed = Double(now &- lastTelemetryAt) / 1_000_000_000
158+
achieved = FrameTelemetry.rate(from: previous, to: current, elapsed: elapsed).achievedFrameRate
159+
}
160+
// Advance the baseline every tick — even when `base` is nil (reconnecting) —
161+
// so the next window measures against a fresh, ~1s-old sample.
162+
lastTelemetrySnapshot = current
163+
lastTelemetryAt = now
164+
guard var stats = base else { return nil }
165+
stats.achievedFrameRate = achieved
166+
stats.droppedFrames = current.congestionDrops
167+
return stats
168+
}
169+
130170
/// Assigns the latest telemetry, skipping a redundant write (and the 1 Hz leaf
131171
/// re-render it would otherwise trigger) when the snapshot is unchanged.
132172
private func updateLiveStats(_ stats: LiveStats?) {
133173
if liveStats != stats { liveStats = stats }
134174
}
135175

136-
private func makePublisher(for transport: StreamCore.StreamProtocol) -> any Publisher {
176+
private func makePublisher(for transport: StreamCore.StreamProtocol,
177+
telemetry: FrameTelemetry) -> any Publisher {
137178
switch transport {
138179
case .rtmp, .rtmps:
139-
RTMPPublisher()
180+
RTMPPublisher(telemetry: telemetry)
140181
case .srt, .whip:
141-
SessionPublisher(protocol: transport)
182+
SessionPublisher(protocol: transport, telemetry: telemetry)
142183
}
143184
}
144185

@@ -152,8 +193,15 @@ final class ScreenCaptureController: NSObject {
152193
return
153194
}
154195

155-
let publisher = makePublisher(for: settings.selectedProtocol)
156-
let output = ScreenCaptureOutput(publisher: publisher, settings: settings) { [weak self] error in
196+
// One telemetry instance per broadcast, shared by the capture output (the
197+
// four shed sites) and the publisher (admission + encoded rate).
198+
let telemetry = FrameTelemetry()
199+
frameTelemetry = telemetry
200+
lastTelemetrySnapshot = nil
201+
lastTelemetryAt = 0
202+
let publisher = makePublisher(for: settings.selectedProtocol, telemetry: telemetry)
203+
let output = ScreenCaptureOutput(publisher: publisher, settings: settings,
204+
telemetry: telemetry) { [weak self] error in
157205
Task { @MainActor [weak self] in await self?.captureDidStop(error: error) }
158206
}
159207

@@ -228,6 +276,9 @@ final class ScreenCaptureController: NSObject {
228276
heartbeatTask = nil
229277
statsTask?.cancel()
230278
statsTask = nil
279+
frameTelemetry = nil
280+
lastTelemetrySnapshot = nil
281+
lastTelemetryAt = 0
231282
isLive = false
232283
thermalNotice = nil
233284
liveStats = nil
@@ -408,6 +459,10 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
408459
/// the capture pacing so a 1080p60 pick never asks a device for more than it
409460
/// can sustain. The thermal governor tightens this further at runtime.
410461
private let capability = StreamCapability.current
462+
/// Shared frame telemetry (issue #23 / M8): this output records the capture,
463+
/// pacing (PTS-deadline), backpressure (`bufferingNewest(1)`), and compositor
464+
/// (pool-exhaustion) sites on the serial sample queue.
465+
private let telemetry: FrameTelemetry
411466
private let onStopped: @Sendable (Error) -> Void
412467
private let micLevelMeter: ScreenCaptureMicrophoneMeter
413468
private let micLevelChannel = MicrophoneLevelChannel()
@@ -427,9 +482,11 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
427482

428483
init(publisher: any Publisher,
429484
settings: StreamSettings,
485+
telemetry: FrameTelemetry,
430486
onStopped: @escaping @Sendable (Error) -> Void) {
431487
self.publisher = publisher
432488
self.settings = settings
489+
self.telemetry = telemetry
433490
self.onStopped = onStopped
434491
micLevelMeter = ScreenCaptureMicrophoneMeter(gain: settings.micVolume)
435492
targetFrameInterval = CMTime(value: 1,
@@ -454,22 +511,28 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
454511
await publisher.setOutputSize(target, nativeShortEdge: min(width, height))
455512
targetSize = target
456513
}
457-
if settings.pipEnabled, let targetSize,
458-
let camera = self.facecam.latest.take(),
459-
let composited = self.compositor.composite(
460-
screen: image,
461-
camera: camera,
462-
targetSize: targetSize,
463-
orientation: .up,
464-
corner: settings.pipCorner,
465-
scale: settings.pipScale,
466-
cameraPosition: settings.cameraPosition
467-
),
468-
let output = self.compositor.makeSampleBuffer(
469-
from: composited,
470-
timingSource: sampleBuffer
471-
) {
472-
await publisher.appendVideo(output)
514+
if settings.pipEnabled, let targetSize, let camera = self.facecam.latest.take() {
515+
if let composited = self.compositor.composite(
516+
screen: image,
517+
camera: camera,
518+
targetSize: targetSize,
519+
orientation: .up,
520+
corner: settings.pipCorner,
521+
scale: settings.pipScale,
522+
cameraPosition: settings.cameraPosition
523+
),
524+
let output = self.compositor.makeSampleBuffer(
525+
from: composited,
526+
timingSource: sampleBuffer
527+
) {
528+
await publisher.appendVideo(output)
529+
} else {
530+
// Pool exhausted (a slow encoder holding surfaces) or the wrap
531+
// failed: skip the overlay for this frame and send the raw
532+
// screen. The frame itself is NOT lost — the overlay is.
533+
self.telemetry.recordDrop(.compositor)
534+
await publisher.appendVideo(sampleBuffer)
535+
}
473536
} else {
474537
await publisher.appendVideo(sampleBuffer)
475538
}
@@ -491,12 +554,16 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
491554
guard sampleBuffer.isValid, sampleBuffer.dataReadiness == .ready else { return }
492555
switch type {
493556
case .screen:
557+
telemetry.recordCaptured()
494558
// Downsample the native-refresh feed to the target frame rate by PTS
495559
// deadline: emit the first frame at/after each deadline, then advance
496560
// by one interval; re-anchor after a stall so a gap doesn't burst.
497561
let pts = sampleBuffer.presentationTimeStamp
498562
if pts.isValid {
499563
if nextVideoDeadline.isValid, CMTimeCompare(pts, nextVideoDeadline) < 0 {
564+
// Intentional pacing shed (e.g. 120 Hz → 30 fps), not a fault —
565+
// counted separately from the congestion drops the HUD surfaces.
566+
telemetry.recordDrop(.pacing)
500567
return // arrived before the next target-fps slot — drop it
501568
}
502569
let advanced = CMTimeAdd(nextVideoDeadline.isValid ? nextVideoDeadline : pts,
@@ -505,7 +572,12 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
505572
? advanced
506573
: CMTimeAdd(pts, targetFrameInterval)
507574
}
508-
videoContinuation.yield(sampleBuffer)
575+
// `bufferingNewest(1)`: if the consumer (compositor/append) hasn't drained
576+
// the previous frame, this yield evicts it — a backpressure shed the
577+
// pipeline couldn't keep up with. `.dropped` carries that evicted frame.
578+
if case .dropped = videoContinuation.yield(sampleBuffer) {
579+
telemetry.recordDrop(.backpressure)
580+
}
509581
case .audio:
510582
if settings.includeAppAudio { publisher.enqueueApp(sampleBuffer) }
511583
case .microphone:

Stream/SettingsView.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,8 @@ private final class MicrophoneLevelMonitor {
10221022
/// A leaf view so its ~1s telemetry ticks invalidate only this subtree (not the
10231023
/// whole launcher List), and every value is monospaced + single-line so the card's
10241024
/// height is invariant per tick — the content-sized drawer detent never springs on
1025-
/// a number change (only an occasional thermal-notice appearance resizes it).
1025+
/// a number change (only an occasional thermal- or dropped-frames notice appearing
1026+
/// resizes it).
10261027
private struct LiveStatsCard: View {
10271028
var capture: ScreenCaptureController
10281029

@@ -1037,6 +1038,7 @@ private struct LiveStatsCard: View {
10371038
.lineLimit(1)
10381039
.minimumScaleFactor(0.8)
10391040
}
1041+
droppedNotice
10401042
}
10411043
.padding(14)
10421044
.frame(maxWidth: .infinity, alignment: .leading)
@@ -1062,18 +1064,32 @@ private struct LiveStatsCard: View {
10621064
}
10631065
}
10641066

1065-
/// Bitrate (ABR target) · effective fps · uplink health. Values are the
1066-
/// encoder's applied targets, not measured throughput (measured fps needs M8).
1067+
/// Bitrate (ABR target) · achieved fps · uplink health. Bitrate is the encoder's
1068+
/// applied target; FPS is the MEASURED achieved rate (M8), falling back to the
1069+
/// target for the first second before a rate is available.
10671070
private var metricsRow: some View {
10681071
HStack(spacing: 0) {
10691072
metric("Bitrate", value: capture.liveStats?.bitRateLabel ?? "")
10701073
divider
1071-
metric("FPS", value: capture.liveStats.map { "\($0.frameRate)" } ?? "")
1074+
metric("FPS", value: capture.liveStats.map { "\($0.displayFrameRate)" } ?? "")
10721075
divider
10731076
metric("Uplink", value: capture.liveStats?.linkHealth.label ?? "", tint: uplinkTint)
10741077
}
10751078
}
10761079

1080+
/// A subtle count of the frames congestion has shed this session (backpressure +
1081+
/// admission). Hidden until the first drop, so a healthy stream shows nothing;
1082+
/// the coloured Uplink metric already carries the live severity signal.
1083+
@ViewBuilder private var droppedNotice: some View {
1084+
if let stats = capture.liveStats, stats.droppedFrames > 0 {
1085+
Label("\(stats.droppedLabel) frames dropped", systemImage: "square.stack.3d.up.slash")
1086+
.font(.caption2)
1087+
.foregroundStyle(.secondary)
1088+
.lineLimit(1)
1089+
.minimumScaleFactor(0.8)
1090+
}
1091+
}
1092+
10771093
private var divider: some View { Divider().frame(height: 26) }
10781094

10791095
private var uplinkTint: Color {

StreamBroadcast/RTMPPublisher.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ actor RTMPPublisher: Publisher {
5959
/// Sheds input video frames when the outbound queue is deep, to bound latency
6060
/// (HaishinKit's send queue is otherwise unbounded). Refreshed by checkNetworkHealth.
6161
private let videoAdmission = VideoFrameAdmission()
62+
/// Shared frame-drop / achieved-fps counters (issue #23 / M8). This actor records
63+
/// the admission shed + every encoded append; the capture side records the
64+
/// capture/pacing/backpressure/compositor sites into the same instance, and the
65+
/// controller reads one snapshot per telemetry poll. Reachable here so the
66+
/// adaptive logic (M9) can consume the congestion-drop signal.
67+
private let telemetry: FrameTelemetry
6268

6369
// Ordered, lossless audio ingress: ScreenCaptureKit yields synchronously, a
6470
// single consumer per track awaits each append — preserving PTS order so
@@ -80,7 +86,8 @@ actor RTMPPublisher: Publisher {
8086
/// Shared outbound-queue stall watchdog state (stalledTicks + lastQueueBytes).
8187
private var watchdog = WatchdogState()
8288

83-
init() {
89+
init(telemetry: FrameTelemetry = FrameTelemetry()) {
90+
self.telemetry = telemetry
8491
(micStream, micCont) = AsyncStream.makeStream(of: CMSampleBuffer.self, bufferingPolicy: .unbounded)
8592
(appStream, appCont) = AsyncStream.makeStream(of: CMSampleBuffer.self, bufferingPolicy: .unbounded)
8693
}
@@ -770,10 +777,14 @@ actor RTMPPublisher: Publisher {
770777
lastVideoBuffer = sb
771778
// Under outbound congestion, drop a proportion of video frames (audio is
772779
// never dropped) so latency stays bounded instead of the queue growing.
773-
guard videoAdmission.admit() else { return }
780+
guard videoAdmission.admit() else {
781+
telemetry.recordDrop(.admission)
782+
return
783+
}
774784
let duration = CMTime(value: 1,
775785
timescale: CMTimeScale(encodeFrameRate))
776786
let normalized = timeline.normalize(sb, kind: .video, fallbackDuration: duration)
787+
telemetry.recordEncoded()
777788
await mixer.append(enforceMonotonicVideo(normalized, minStep: duration))
778789
}
779790

StreamBroadcast/SessionPublisher.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ actor SessionPublisher: Publisher {
6969

7070
private var timeline = MediaTimelineNormalizer()
7171
private let videoAdmission = VideoFrameAdmission()
72+
/// Shared frame-drop / achieved-fps counters (issue #23 / M8). See RTMPPublisher:
73+
/// records the admission shed + every encoded append into the same instance the
74+
/// capture side and controller share.
75+
private let telemetry: FrameTelemetry
7276

7377
// Ordered, lossless audio ingress: ScreenCaptureKit yields synchronously, a
7478
// single consumer per track awaits each append — preserving PTS order.
@@ -123,8 +127,10 @@ actor SessionPublisher: Publisher {
123127
/// The reconnect loop's currently-sleeping backoff; cancelling = "retry now".
124128
private var backoffSleepTask: Task<Void, any Error>?
125129

126-
init(protocol streamProtocol: StreamCore.StreamProtocol) {
130+
init(protocol streamProtocol: StreamCore.StreamProtocol,
131+
telemetry: FrameTelemetry = FrameTelemetry()) {
127132
self.transport = streamProtocol
133+
self.telemetry = telemetry
128134
(micStream, micCont) = AsyncStream.makeStream(of: CMSampleBuffer.self, bufferingPolicy: .unbounded)
129135
(appStream, appCont) = AsyncStream.makeStream(of: CMSampleBuffer.self, bufferingPolicy: .unbounded)
130136
}
@@ -644,9 +650,13 @@ actor SessionPublisher: Publisher {
644650
lastMediaAt = now
645651
lastVideoAppendAt = now
646652
lastVideoBuffer = sb
647-
guard videoAdmission.admit() else { return }
653+
guard videoAdmission.admit() else {
654+
telemetry.recordDrop(.admission)
655+
return
656+
}
648657
let duration = CMTime(value: 1, timescale: CMTimeScale(encodeFrameRate))
649658
let normalized = timeline.normalize(sb, kind: .video, fallbackDuration: duration)
659+
telemetry.recordEncoded()
650660
await mixer.append(enforceMonotonicVideo(normalized, minStep: duration))
651661
}
652662

0 commit comments

Comments
 (0)